From 00d33c4ad673569f6c598835a01f805475071df3 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Tue, 18 Aug 2026 14:55:23 -0300 Subject: [PATCH 1/8] feat(browser): host the scrapling surface natively as browser::scrapling::* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the scrapling surface from the Python worker to native Rust inside the browser worker: 19 browser::* functions (10 parse ops + fetch, dynamic/stealthy fetch, screenshot-url, sessions, crawl). Parse surface (css, xpath, find, find-by-text/regex, find-similar, describe, extract, to-markdown, regex): - dom layer with lxml-aligned text/blank-text semantics on a vendored xmloxide parser; vendored cssselect (parsel pseudo-elements) and rustpython sre_engine (Python-regex parity) - detached ::text/::attr() descendant idiom; xpath subset lexer/parser/evaluator with recursion cap; generated-selector port; difflib-parity find-similar scoring; markdownify-parity to-markdown - parse ops run via spawn_blocking so a pathological regex cannot stall the shared runtime - schemas pinned to read-only Python goldens (locked oracle environment under oracle/), differential behavior-fixture harness, e2e harness + CI workflow Fetch surface: - safe mode (default): reqwest with per-hop SSRF re-validation and socket pinning, redirect cookie replay (host-scoped), clamped timeouts/redirects/retry delays, total-budget enforcement inside an attempt - browser tiers on a private pipe-based CDP client (no chromiumoxide): per-command 180s ceiling, recoverable Lagged handling, any-session child-target routing (OOPIF/worker resume), target close on every error path, bounded Cloudflare solve - egress gate: local HTTP/CONNECT proxy pinning Chromium's traffic to SSRF-validated addresses; plain-HTTP requests forced Connection:close so a keep-alive client cannot reuse a pinned socket cross-host - SSRF blocklist covers NAT64/6to4/IPv4-compatible v6 embeds - compat mode (feature scrapling-compat, Tier-1 Linux only): vendored curl_impersonate for fingerprint-faithful fetches; exact Chrome pin applies to compat only — safe mode accepts any system Chromium - sessions registry with pending-slot leak guard, insertion-order pruning, bounded shutdown; session/fetch/crawl policy lists aligned so compat-only options are refused consistently Configuration and console: - scrapling.* config block: security_mode, chromium_executable, allow_loopback, defaults, concurrency/session caps, adaptive storage quota, inject_guidance - inject_guidance hot-applies via the shared iii-config-client BindingSlot: console flips bind/unbind the pre-generate guidance hook live (guidance carried statically via metadata.inject_prompt) - custom config form gains a Scraping section with the guidance toggle; scrapling function-trigger renderers for the console chat - chromiumoxide::handler demoted to error by default: its bindings lag the system Chromium, and protocol-skew frames it drops anyway were WARN-spamming per page load (operator RUST_LOG mentioning chromiumoxide always wins) The standalone scrapling-native crate is deleted; its release wiring went with .github/release-workers.yaml (removed upstream in #765). --- .github/workflows/browser-scrapling-e2e.yml | 294 + .gitignore | 2 + browser/Cargo.lock | 758 +- browser/Cargo.toml | 41 +- browser/README.md | 211 +- browser/examples/scrapling_differential.rs | 21 + .../examples/scrapling_http_differential.rs | 34 + browser/iii-permissions.yaml | 24 + browser/iii.worker.yaml | 10 +- browser/oracle/README.md | 48 + browser/oracle/manifest.json | 12469 ++++++++++++++++ browser/oracle/requirements.in | 15 + browser/oracle/requirements.lock | 983 ++ browser/scripts/differential_http.py | 367 + browser/scripts/differential_parser.py | 238 + browser/scripts/fetch_chromium_artifacts.sh | 79 + .../fetch_curl_impersonate_artifacts.sh | 101 + browser/scripts/gen_goldens.py | 649 + browser/scripts/verify_oracle.py | 296 + browser/skills/SKILL.md | 105 +- browser/src/config.rs | 313 +- browser/src/configuration.rs | 20 +- browser/src/lib.rs | 8 +- browser/src/logging.rs | 99 + browser/src/main.rs | 46 +- browser/src/manifest.rs | 3 +- browser/src/scrapling/adaptive.rs | 685 + browser/src/scrapling/browserforge.rs | 630 + browser/src/scrapling/cdp.rs | 842 ++ browser/src/scrapling/crawl.rs | 896 ++ browser/src/scrapling/dom.rs | 521 + browser/src/scrapling/egress_gate.rs | 260 + browser/src/scrapling/fetch.rs | 1617 ++ browser/src/scrapling/fetch/curl_compat.rs | 961 ++ browser/src/scrapling/inject_guidance.rs | 132 + browser/src/scrapling/markdown.rs | 1235 ++ browser/src/scrapling/mod.rs | 260 + browser/src/scrapling/net.rs | 524 + browser/src/scrapling/ops/common.rs | 86 + browser/src/scrapling/ops/css_fn.rs | 70 + browser/src/scrapling/ops/describe.rs | 80 + browser/src/scrapling/ops/extract.rs | 125 + browser/src/scrapling/ops/find.rs | 105 + browser/src/scrapling/ops/find_by_regex.rs | 36 + browser/src/scrapling/ops/find_by_text.rs | 75 + browser/src/scrapling/ops/find_similar.rs | 97 + browser/src/scrapling/ops/mod.rs | 16 + browser/src/scrapling/ops/regex_fn.rs | 54 + browser/src/scrapling/ops/to_markdown.rs | 25 + browser/src/scrapling/ops/xpath_fn.rs | 83 + browser/src/scrapling/page.rs | 274 + browser/src/scrapling/query.rs | 61 + browser/src/scrapling/raw_browser.rs | 2895 ++++ browser/src/scrapling/schemas.rs | 726 + browser/src/scrapling/selgen.rs | 149 + browser/src/scrapling/sessions.rs | 909 ++ browser/src/scrapling/similar.rs | 419 + browser/src/scrapling/text.rs | 358 + browser/src/scrapling/xpath/mod.rs | 98 + browser/src/ssrf.rs | 400 + browser/tests/adaptive.rs | 243 + browser/tests/behavior.rs | 72 + browser/tests/browser_compat.rs | 254 + browser/tests/cdp_private.rs | 299 + browser/tests/corpus/basic.html | 1 + browser/tests/corpus/browser_visual.html | 8 + browser/tests/corpus/edge.html | 17 + browser/tests/corpus/messy.html | 19 + browser/tests/cssselect_compat.rs | 29 + browser/tests/e2e/.gitignore | 16 + browser/tests/e2e/README.md | 163 + browser/tests/e2e/config.yaml | 38 + browser/tests/e2e/reports/.gitkeep | 0 browser/tests/e2e/run-tests.sh | 292 + .../e2e/workers/harness/package-lock.json | 1126 ++ .../tests/e2e/workers/harness/package.json | 20 + .../tests/e2e/workers/harness/src/cases.ts | 595 + .../tests/e2e/workers/harness/src/runner.ts | 123 + .../tests/e2e/workers/harness/src/worker.ts | 46 + .../tests/e2e/workers/harness/tsconfig.json | 14 + .../golden/behavior/css/all_default.json | 14 + .../golden/behavior/css/attr_miss_in_all.json | 15 + .../behavior/css/bare_detached_text.json | 16 + .../css/detached_text_descendants.json | 14 + .../behavior/css/detached_text_first.json | 12 + .../css/empty_attr_falls_back_to_text.json | 13 + .../tests/golden/behavior/css/first_attr.json | 13 + .../tests/golden/behavior/css/first_text.json | 12 + .../css/general_sibling_attr_pseudo.json | 14 + .../css/grouped_selector_document_order.json | 15 + .../golden/behavior/css/invalid_selector.json | 9 + .../golden/behavior/css/no_match_first.json | 12 + .../golden/behavior/css/no_match_modes.json | 11 + .../css/nth_not_and_attribute_operators.json | 12 + .../golden/behavior/css/pseudo_attr.json | 14 + .../golden/behavior/css/pseudo_text.json | 12 + .../behavior/css/sibling_text_pseudo.json | 14 + .../behavior/css/template_child_css.json | 12 + .../behavior/css/text_pseudo_messy_main.json | 18 + .../golden/behavior/describe/h1_css.json | 29 + .../describe/id_shortcircuit_full.json | 25 + .../golden/behavior/describe/no_match.json | 11 + .../golden/behavior/describe/text_pseudo.json | 25 + .../describe/weird_kind_is_xpath.json | 30 + .../golden/behavior/describe/xpath_kind.json | 28 + .../comments_removed_and_text_merged.json | 27 + .../behavior/extract/detached_text_spec.json | 26 + .../behavior/extract/empty_selectors.json | 11 + .../entities_and_invalid_codepoints.json | 27 + .../foreign_content_serialization.json | 19 + .../extract/malformed_table_recovery.json | 19 + .../misnested_formatting_recovery.json | 19 + .../golden/behavior/extract/mixed_specs.json | 48 + .../behavior/extract/regex_all_spec.json | 22 + .../behavior/extract/spec_without_query.json | 22 + .../extract/template_nested_content.json | 27 + .../golden/behavior/extract/xpath_specs.json | 28 + .../find-by-regex/default_insensitive.json | 21 + .../behavior/find-by-regex/limit_zero.json | 13 + .../behavior/find-by-regex/messy_cards.json | 37 + .../find-by-regex/sensitive_miss.json | 13 + .../unicode_ignorecase_extra.json | 21 + .../find-by-text/case_sensitive_miss.json | 13 + .../find-by-text/clean_match_whitespace.json | 21 + .../clean_trims_trailing_space.json | 21 + .../behavior/find-by-text/exact_default.json | 23 + .../behavior/find-by-text/first_flag.json | 24 + .../find-by-text/no_clean_exact_ws.json | 22 + .../no_clean_keeps_trailing_space.json | 13 + .../golden/behavior/find-by-text/none.json | 12 + .../behavior/find-by-text/partial_case.json | 24 + .../behavior/find-similar/anchor_missing.json | 12 + .../find-similar/cards_attr_scoring.json | 25 + .../find-similar/cards_high_threshold.json | 18 + .../behavior/find-similar/list_items.json | 21 + .../behavior/find-similar/match_text.json | 22 + .../match_text_multiline_leading.json | 23 + .../subselector_scope_cannot_escape.json | 31 + .../behavior/find-similar/subselectors.json | 26 + .../behavior/find/attrs_bool_coercion.json | 15 + .../find/attrs_exact_whole_value.json | 37 + .../find/attrs_operator_contains.json | 25 + .../behavior/find/attrs_operator_prefix.json | 35 + .../tests/golden/behavior/find/by_tag.json | 33 + .../behavior/find/by_tag_input_attrs_map.json | 24 + .../golden/behavior/find/by_tag_list.json | 34 + .../duplicate_and_boolean_attributes.json | 26 + .../behavior/find/empty_text_regex_error.json | 9 + .../behavior/find/implied_document_nodes.json | 41 + .../golden/behavior/find/limit_clamps.json | 30 + .../behavior/find/negative_limit_empty.json | 13 + .../behavior/find/no_filters_error.json | 8 + .../behavior/find/star_tag_falls_through.json | 91 + .../behavior/find/tag_and_text_regex.json | 25 + .../golden/behavior/find/tag_html_root.json | 21 + .../find/text_regex_only_all_elements.json | 21 + .../behavior/regex/across_fragments_edge.json | 13 + .../behavior/regex/atomic_and_possessive.json | 13 + .../golden/behavior/regex/entities_edge.json | 13 + .../golden/behavior/regex/first_group.json | 12 + .../regex/invalid_global_flag_position.json | 9 + .../regex/invalid_group_reference.json | 9 + .../golden/behavior/regex/invalid_range.json | 9 + .../regex/invalid_unterminated_group.json | 9 + .../regex/invalid_variable_lookbehind.json | 9 + .../regex/lookbehind_and_conditional.json | 22 + .../behavior/regex/named_backreference.json | 13 + .../golden/behavior/regex/no_match_all.json | 11 + .../golden/behavior/regex/no_match_first.json | 12 + .../golden/behavior/regex/smoke_all.json | 14 + .../golden/behavior/regex/two_groups.json | 16 + .../behavior/regex/unicode_name_escape.json | 13 + .../behavior/regex/w3lib_html4_entities.json | 18 + .../behavior/regex/zero_width_findall.json | 15 + .../behavior/to-markdown/bad_format.json | 9 + .../to-markdown/hidden_body_self_exempt.json | 13 + .../behavior/to-markdown/html_roundtrip.json | 12 + .../behavior/to-markdown/markdown_basic.json | 11 + .../markdown_blocks_and_lists.json | 11 + .../markdown_html_parser_second_parse.json | 11 + .../to-markdown/markdown_inline_defaults.json | 11 + .../to-markdown/markdown_table_and_video.json | 11 + .../markdown_unknown_and_noise_tags.json | 11 + .../pseudo_text_selector_html_mode.json | 13 + .../pseudo_text_selector_text_mode.json | 13 + .../behavior/to-markdown/scoped_css.json | 13 + .../behavior/to-markdown/text_basic.json | 12 + .../to-markdown/text_messy_main_only.json | 13 + .../behavior/xpath/all_anchors_text.json | 14 + .../xpath/ancestor_axis_reverse_position.json | 13 + .../behavior/xpath/attr_axis_terminal.json | 14 + .../xpath/attr_param_on_elements.json | 15 + .../xpath/attribute_wildcard_order.json | 15 + .../golden/behavior/xpath/contains_href.json | 12 + .../xpath/explicit_axis_after_slashslash.json | 13 + .../xpath/false_scalar_becomes_empty.json | 11 + .../tests/golden/behavior/xpath/first_h1.json | 12 + .../xpath/following_axis_document_order.json | 14 + .../xpath/global_parenthesized_position.json | 13 + .../golden/behavior/xpath/invalid_syntax.json | 9 + .../xpath/number_scalar_type_error.json | 9 + .../golden/behavior/xpath/positional.json | 12 + .../preceding_axis_reverse_position.json | 13 + .../xpath/predicate_arithmetic_and_round.json | 13 + .../behavior/xpath/predicate_attr_value.json | 14 + .../xpath/predicate_string_functions.json | 13 + .../string_scalar_splits_into_text_nodes.json | 9 + .../behavior/xpath/template_child_step.json | 13 + .../behavior/xpath/text_runs_body_messy.json | 16 + .../behavior/xpath/text_runs_main_messy.json | 18 + .../golden/behavior/xpath/text_terminal.json | 12 + .../xpath/textarea_blank_body_kept.json | 13 + .../xpath/true_scalar_type_error.json | 9 + .../behavior/xpath/union_doc_order.json | 14 + .../xpath/unknown_function_error.json | 9 + .../golden/browser/dynamic-full-png-1.png | Bin 0 -> 7554 bytes .../golden/browser/dynamic-viewport-png-1.png | Bin 0 -> 3069 bytes browser/tests/golden/browser/manifest.json | 128 + .../golden/browser/stealthy-full-jpeg-1.jpg | Bin 0 -> 19067 bytes .../browser/stealthy-viewport-png-1.png | Bin 0 -> 3091 bytes browser/tests/golden/schemas/browser.act.json | 64 +- .../golden/schemas/browser.console.read.json | 114 +- .../tests/golden/schemas/browser.crawl.json | 153 + browser/tests/golden/schemas/browser.css.json | 59 + .../golden/schemas/browser.describe.json | 81 + .../tests/golden/schemas/browser.doctor.json | 78 +- .../golden/schemas/browser.dom.read.json | 78 +- .../golden/schemas/browser.dynamic-fetch.json | 215 + .../golden/schemas/browser.evaluate.json | 36 +- .../tests/golden/schemas/browser.execute.json | 44 +- .../tests/golden/schemas/browser.extract.json | 71 + .../tests/golden/schemas/browser.fetch.json | 193 + .../golden/schemas/browser.find-by-regex.json | 65 + .../golden/schemas/browser.find-by-text.json | 70 + .../golden/schemas/browser.find-similar.json | 75 + .../tests/golden/schemas/browser.find.json | 73 + .../tests/golden/schemas/browser.frame.json | 58 +- .../schemas/browser.handoff.confirm.json | 22 +- .../tests/golden/schemas/browser.handoff.json | 42 +- .../tests/golden/schemas/browser.history.json | 32 +- .../golden/schemas/browser.navigate.json | 40 +- .../golden/schemas/browser.network.read.json | 120 +- .../golden/schemas/browser.pick.hint.json | 98 +- .../golden/schemas/browser.pick.resolve.json | 38 +- .../golden/schemas/browser.pick.start.json | 26 +- .../golden/schemas/browser.pick.stop.json | 26 +- .../schemas/browser.recording.start.json | 34 +- .../schemas/browser.recording.stop.json | 38 +- .../tests/golden/schemas/browser.regex.json | 40 + .../schemas/browser.screencast.start.json | 26 +- .../schemas/browser.screencast.stop.json | 26 +- .../schemas/browser.screenshot-url.json | 87 + .../golden/schemas/browser.screenshot.json | 86 +- .../golden/schemas/browser.session-close.json | 23 + .../golden/schemas/browser.session-fetch.json | 151 + .../golden/schemas/browser.session-list.json | 46 + .../golden/schemas/browser.session-open.json | 60 + .../schemas/browser.sessions.attach.json | 38 +- .../golden/schemas/browser.sessions.list.json | 64 +- .../schemas/browser.sessions.start.json | 32 +- .../golden/schemas/browser.sessions.stop.json | 28 +- .../golden/schemas/browser.snapshot.json | 100 +- .../schemas/browser.stealthy-fetch.json | 221 + .../golden/schemas/browser.styles.read.json | 76 +- .../golden/schemas/browser.styles.write.json | 36 +- .../golden/schemas/browser.tabs.list.json | 54 +- .../golden/schemas/browser.to-markdown.json | 42 + .../tests/golden/schemas/browser.xpath.json | 59 + browser/tests/integration.rs | 42 +- browser/tests/scrapling_schemas.rs | 90 + browser/tests/support/mod.rs | 35 + browser/tests/xmloxide_compat.rs | 92 + browser/ui/package.json | 5 +- browser/ui/page.tsx | 2 + browser/ui/src/configuration/index.tsx | 31 +- .../ui/src/function-trigger-message/index.tsx | 2 +- .../scrapling/CrawlView.tsx | 188 + .../scrapling/FetchView.tsx | 347 + .../scrapling/MarkdownView.tsx | 82 + .../scrapling/ParseViews.tsx | 309 + .../scrapling/ScreenshotView.tsx | 114 + .../scrapling/SearchViews.tsx | 246 + .../scrapling/SessionViews.tsx | 354 + .../scrapling/index.test.tsx | 348 + .../scrapling/index.tsx | 166 + .../scrapling/parsers.test.ts | 50 + .../scrapling/parsers.ts | 448 + browser/ui/src/lib/shared.tsx | 15 + browser/ui/styles.css | 260 + browser/vendor/SCRAPLING-0.4.9-LICENSE | 29 + browser/vendor/browserforge-1.2.4/NOTICE | 10 + .../browserforge-1.2.4/header-network.json | 1 + .../browserforge-1.2.4/input-network.json | 1 + browser/vendor/cssselect/Cargo.toml | 17 + browser/vendor/cssselect/LICENSE | 32 + browser/vendor/cssselect/README.md | 62 + browser/vendor/cssselect/src/error.rs | 29 + browser/vendor/cssselect/src/html.rs | 88 + browser/vendor/cssselect/src/lib.rs | 52 + browser/vendor/cssselect/src/parser.rs | 1181 ++ browser/vendor/cssselect/src/tokenizer.rs | 479 + browser/vendor/cssselect/src/util.rs | 99 + browser/vendor/cssselect/src/xpath.rs | 1072 ++ browser/vendor/cssselect/tests/canonical.rs | 78 + .../cssselect/tests/fixtures/html_ids.html | 49 + .../tests/fixtures/operator_precedence.xml | 6 + .../cssselect/tests/fixtures/shakespeare.html | 309 + .../cssselect/tests/fixtures/xmllang.xml | 12 + .../vendor/cssselect/tests/html_translator.rs | 75 + .../vendor/cssselect/tests/parse_errors.rs | 117 + browser/vendor/cssselect/tests/parser_repr.rs | 210 + .../vendor/cssselect/tests/pseudo_elements.rs | 199 + browser/vendor/cssselect/tests/quoting.rs | 96 + browser/vendor/cssselect/tests/select.rs | 114 + browser/vendor/cssselect/tests/series.rs | 78 + browser/vendor/cssselect/tests/specificity.rs | 69 + browser/vendor/cssselect/tests/tokenizer.rs | 116 + browser/vendor/cssselect/tests/translation.rs | 374 + browser/vendor/cssselect/tests/xpath_expr.rs | 87 + .../vendor/curl_impersonate_sys/.gitignore | 3 + .../vendor/curl_impersonate_sys/Cargo.toml | 13 + browser/vendor/curl_impersonate_sys/README.md | 74 + .../curl_impersonate_sys/UPSTREAM_LICENSE | 21 + .../curl_impersonate_sys/artifacts.manifest | 3 + browser/vendor/curl_impersonate_sys/build.rs | 168 + .../vendor/curl_impersonate_sys/src/lib.rs | 202 + .../curl_impersonate_sys/tests/manifest.rs | 34 + ...kdownify-1.2.3-beautifulsoup-4.15.0.NOTICE | 59 + .../vendor/rustpython-sre_engine/Cargo.lock | 328 + .../vendor/rustpython-sre_engine/Cargo.toml | 16 + .../rustpython-sre_engine/Cargo.toml.orig | 27 + browser/vendor/rustpython-sre_engine/LICENSE | 21 + .../vendor/rustpython-sre_engine/README.md | 8 + .../examples/differential_driver.rs | 89 + .../rustpython-sre_engine/src/compiler.rs | 1645 ++ .../rustpython-sre_engine/src/constants.rs | 130 + .../rustpython-sre_engine/src/engine.rs | 1423 ++ .../vendor/rustpython-sre_engine/src/lib.rs | 24 + .../rustpython-sre_engine/src/string.rs | 531 + .../tests/differential.py | 188 + .../vendor/scrapling-0.4.9-ad-domains.NOTICE | 6 + browser/vendor/scrapling-0.4.9-ad-domains.txt | 3529 +++++ browser/vendor/tld-0.13.2-psl.NOTICE | 15 + browser/vendor/xmloxide/Cargo.toml | 23 + browser/vendor/xmloxide/LICENSE | 21 + browser/vendor/xmloxide/README.md | 389 + .../xmloxide/benches/comparison_bench.rs | 224 + .../xmloxide/benches/ecosystem_bench.rs | 252 + .../vendor/xmloxide/benches/parser_bench.rs | 626 + .../vendor/xmloxide/examples/basic_parse.rs | 54 + browser/vendor/xmloxide/examples/c14n.rs | 45 + .../xmloxide/examples/error_recovery.rs | 48 + browser/vendor/xmloxide/examples/ffi_usage.c | 89 + .../vendor/xmloxide/examples/html_parse.rs | 65 + .../vendor/xmloxide/examples/push_parser.rs | 65 + browser/vendor/xmloxide/examples/reader.rs | 73 + .../vendor/xmloxide/examples/sax_streaming.rs | 82 + browser/vendor/xmloxide/examples/serialize.rs | 39 + .../vendor/xmloxide/examples/validation.rs | 112 + browser/vendor/xmloxide/examples/xinclude.rs | 47 + .../vendor/xmloxide/examples/xpath_query.rs | 61 + .../vendor/xmloxide/include/libxml2_compat.h | 316 + browser/vendor/xmloxide/include/xmloxide.h | 1007 ++ browser/vendor/xmloxide/src/async_xml.rs | 146 + browser/vendor/xmloxide/src/bin/xmllint.rs | 923 ++ browser/vendor/xmloxide/src/catalog/mod.rs | 1196 ++ browser/vendor/xmloxide/src/css/eval.rs | 987 ++ browser/vendor/xmloxide/src/css/mod.rs | 340 + browser/vendor/xmloxide/src/css/parser.rs | 580 + browser/vendor/xmloxide/src/css/types.rs | 138 + browser/vendor/xmloxide/src/encoding/mod.rs | 451 + browser/vendor/xmloxide/src/error/mod.rs | 159 + browser/vendor/xmloxide/src/ffi/c14n.rs | 99 + browser/vendor/xmloxide/src/ffi/catalog.rs | 144 + browser/vendor/xmloxide/src/ffi/css.rs | 133 + browser/vendor/xmloxide/src/ffi/document.rs | 328 + browser/vendor/xmloxide/src/ffi/html5.rs | 99 + browser/vendor/xmloxide/src/ffi/mod.rs | 135 + browser/vendor/xmloxide/src/ffi/push.rs | 90 + browser/vendor/xmloxide/src/ffi/reader.rs | 317 + browser/vendor/xmloxide/src/ffi/sax.rs | 193 + browser/vendor/xmloxide/src/ffi/serial.rs | 136 + browser/vendor/xmloxide/src/ffi/strings.rs | 33 + browser/vendor/xmloxide/src/ffi/tree.rs | 734 + browser/vendor/xmloxide/src/ffi/validation.rs | 467 + browser/vendor/xmloxide/src/ffi/xinclude.rs | 37 + browser/vendor/xmloxide/src/ffi/xpath.rs | 305 + browser/vendor/xmloxide/src/html/entities.rs | 388 + browser/vendor/xmloxide/src/html/mod.rs | 1773 +++ browser/vendor/xmloxide/src/html5/entities.rs | 2318 +++ browser/vendor/xmloxide/src/html5/mod.rs | 84 + browser/vendor/xmloxide/src/html5/sax.rs | 399 + .../vendor/xmloxide/src/html5/tokenizer.rs | 3374 +++++ .../vendor/xmloxide/src/html5/tree_builder.rs | 4721 ++++++ browser/vendor/xmloxide/src/lib.rs | 61 + browser/vendor/xmloxide/src/parser/input.rs | 3497 +++++ browser/vendor/xmloxide/src/parser/mod.rs | 243 + browser/vendor/xmloxide/src/parser/push.rs | 509 + browser/vendor/xmloxide/src/parser/xml.rs | 2176 +++ browser/vendor/xmloxide/src/reader/mod.rs | 1676 +++ browser/vendor/xmloxide/src/sax/mod.rs | 915 ++ browser/vendor/xmloxide/src/serde_xml/de.rs | 613 + .../vendor/xmloxide/src/serde_xml/error.rs | 41 + browser/vendor/xmloxide/src/serde_xml/mod.rs | 46 + browser/vendor/xmloxide/src/serde_xml/ser.rs | 887 ++ browser/vendor/xmloxide/src/serial/c14n.rs | 1184 ++ browser/vendor/xmloxide/src/serial/html.rs | 1209 ++ browser/vendor/xmloxide/src/serial/mod.rs | 12 + browser/vendor/xmloxide/src/serial/xml.rs | 768 + browser/vendor/xmloxide/src/tree/mod.rs | 2197 +++ browser/vendor/xmloxide/src/tree/node.rs | 77 + browser/vendor/xmloxide/src/util/dict.rs | 175 + browser/vendor/xmloxide/src/util/mod.rs | 7 + browser/vendor/xmloxide/src/util/qname.rs | 64 + browser/vendor/xmloxide/src/validation/dtd.rs | 3891 +++++ browser/vendor/xmloxide/src/validation/mod.rs | 147 + .../vendor/xmloxide/src/validation/relaxng.rs | 2479 +++ .../xmloxide/src/validation/schematron.rs | 1951 +++ browser/vendor/xmloxide/src/validation/xsd.rs | 3731 +++++ browser/vendor/xmloxide/src/xinclude/mod.rs | 853 ++ browser/vendor/xmloxide/src/xpath/ast.rs | 427 + browser/vendor/xmloxide/src/xpath/eval.rs | 3149 ++++ browser/vendor/xmloxide/src/xpath/lexer.rs | 1099 ++ browser/vendor/xmloxide/src/xpath/mod.rs | 81 + browser/vendor/xmloxide/src/xpath/parser.rs | 1500 ++ browser/vendor/xmloxide/src/xpath/regex.rs | 893 ++ browser/vendor/xmloxide/src/xpath/types.rs | 1007 ++ pnpm-lock.yaml | 6 + 428 files changed, 120691 insertions(+), 823 deletions(-) create mode 100644 .github/workflows/browser-scrapling-e2e.yml create mode 100644 browser/examples/scrapling_differential.rs create mode 100644 browser/examples/scrapling_http_differential.rs create mode 100644 browser/iii-permissions.yaml create mode 100644 browser/oracle/README.md create mode 100644 browser/oracle/manifest.json create mode 100644 browser/oracle/requirements.in create mode 100644 browser/oracle/requirements.lock create mode 100644 browser/scripts/differential_http.py create mode 100644 browser/scripts/differential_parser.py create mode 100755 browser/scripts/fetch_chromium_artifacts.sh create mode 100755 browser/scripts/fetch_curl_impersonate_artifacts.sh create mode 100644 browser/scripts/gen_goldens.py create mode 100644 browser/scripts/verify_oracle.py create mode 100644 browser/src/logging.rs create mode 100644 browser/src/scrapling/adaptive.rs create mode 100644 browser/src/scrapling/browserforge.rs create mode 100644 browser/src/scrapling/cdp.rs create mode 100644 browser/src/scrapling/crawl.rs create mode 100644 browser/src/scrapling/dom.rs create mode 100644 browser/src/scrapling/egress_gate.rs create mode 100644 browser/src/scrapling/fetch.rs create mode 100644 browser/src/scrapling/fetch/curl_compat.rs create mode 100644 browser/src/scrapling/inject_guidance.rs create mode 100644 browser/src/scrapling/markdown.rs create mode 100644 browser/src/scrapling/mod.rs create mode 100644 browser/src/scrapling/net.rs create mode 100644 browser/src/scrapling/ops/common.rs create mode 100644 browser/src/scrapling/ops/css_fn.rs create mode 100644 browser/src/scrapling/ops/describe.rs create mode 100644 browser/src/scrapling/ops/extract.rs create mode 100644 browser/src/scrapling/ops/find.rs create mode 100644 browser/src/scrapling/ops/find_by_regex.rs create mode 100644 browser/src/scrapling/ops/find_by_text.rs create mode 100644 browser/src/scrapling/ops/find_similar.rs create mode 100644 browser/src/scrapling/ops/mod.rs create mode 100644 browser/src/scrapling/ops/regex_fn.rs create mode 100644 browser/src/scrapling/ops/to_markdown.rs create mode 100644 browser/src/scrapling/ops/xpath_fn.rs create mode 100644 browser/src/scrapling/page.rs create mode 100644 browser/src/scrapling/query.rs create mode 100644 browser/src/scrapling/raw_browser.rs create mode 100644 browser/src/scrapling/schemas.rs create mode 100644 browser/src/scrapling/selgen.rs create mode 100644 browser/src/scrapling/sessions.rs create mode 100644 browser/src/scrapling/similar.rs create mode 100644 browser/src/scrapling/text.rs create mode 100644 browser/src/scrapling/xpath/mod.rs create mode 100644 browser/src/ssrf.rs create mode 100644 browser/tests/adaptive.rs create mode 100644 browser/tests/behavior.rs create mode 100644 browser/tests/browser_compat.rs create mode 100644 browser/tests/cdp_private.rs create mode 100644 browser/tests/corpus/basic.html create mode 100644 browser/tests/corpus/browser_visual.html create mode 100644 browser/tests/corpus/edge.html create mode 100644 browser/tests/corpus/messy.html create mode 100644 browser/tests/cssselect_compat.rs create mode 100644 browser/tests/e2e/.gitignore create mode 100644 browser/tests/e2e/README.md create mode 100644 browser/tests/e2e/config.yaml create mode 100644 browser/tests/e2e/reports/.gitkeep create mode 100755 browser/tests/e2e/run-tests.sh create mode 100644 browser/tests/e2e/workers/harness/package-lock.json create mode 100644 browser/tests/e2e/workers/harness/package.json create mode 100644 browser/tests/e2e/workers/harness/src/cases.ts create mode 100644 browser/tests/e2e/workers/harness/src/runner.ts create mode 100644 browser/tests/e2e/workers/harness/src/worker.ts create mode 100644 browser/tests/e2e/workers/harness/tsconfig.json create mode 100644 browser/tests/golden/behavior/css/all_default.json create mode 100644 browser/tests/golden/behavior/css/attr_miss_in_all.json create mode 100644 browser/tests/golden/behavior/css/bare_detached_text.json create mode 100644 browser/tests/golden/behavior/css/detached_text_descendants.json create mode 100644 browser/tests/golden/behavior/css/detached_text_first.json create mode 100644 browser/tests/golden/behavior/css/empty_attr_falls_back_to_text.json create mode 100644 browser/tests/golden/behavior/css/first_attr.json create mode 100644 browser/tests/golden/behavior/css/first_text.json create mode 100644 browser/tests/golden/behavior/css/general_sibling_attr_pseudo.json create mode 100644 browser/tests/golden/behavior/css/grouped_selector_document_order.json create mode 100644 browser/tests/golden/behavior/css/invalid_selector.json create mode 100644 browser/tests/golden/behavior/css/no_match_first.json create mode 100644 browser/tests/golden/behavior/css/no_match_modes.json create mode 100644 browser/tests/golden/behavior/css/nth_not_and_attribute_operators.json create mode 100644 browser/tests/golden/behavior/css/pseudo_attr.json create mode 100644 browser/tests/golden/behavior/css/pseudo_text.json create mode 100644 browser/tests/golden/behavior/css/sibling_text_pseudo.json create mode 100644 browser/tests/golden/behavior/css/template_child_css.json create mode 100644 browser/tests/golden/behavior/css/text_pseudo_messy_main.json create mode 100644 browser/tests/golden/behavior/describe/h1_css.json create mode 100644 browser/tests/golden/behavior/describe/id_shortcircuit_full.json create mode 100644 browser/tests/golden/behavior/describe/no_match.json create mode 100644 browser/tests/golden/behavior/describe/text_pseudo.json create mode 100644 browser/tests/golden/behavior/describe/weird_kind_is_xpath.json create mode 100644 browser/tests/golden/behavior/describe/xpath_kind.json create mode 100644 browser/tests/golden/behavior/extract/comments_removed_and_text_merged.json create mode 100644 browser/tests/golden/behavior/extract/detached_text_spec.json create mode 100644 browser/tests/golden/behavior/extract/empty_selectors.json create mode 100644 browser/tests/golden/behavior/extract/entities_and_invalid_codepoints.json create mode 100644 browser/tests/golden/behavior/extract/foreign_content_serialization.json create mode 100644 browser/tests/golden/behavior/extract/malformed_table_recovery.json create mode 100644 browser/tests/golden/behavior/extract/misnested_formatting_recovery.json create mode 100644 browser/tests/golden/behavior/extract/mixed_specs.json create mode 100644 browser/tests/golden/behavior/extract/regex_all_spec.json create mode 100644 browser/tests/golden/behavior/extract/spec_without_query.json create mode 100644 browser/tests/golden/behavior/extract/template_nested_content.json create mode 100644 browser/tests/golden/behavior/extract/xpath_specs.json create mode 100644 browser/tests/golden/behavior/find-by-regex/default_insensitive.json create mode 100644 browser/tests/golden/behavior/find-by-regex/limit_zero.json create mode 100644 browser/tests/golden/behavior/find-by-regex/messy_cards.json create mode 100644 browser/tests/golden/behavior/find-by-regex/sensitive_miss.json create mode 100644 browser/tests/golden/behavior/find-by-regex/unicode_ignorecase_extra.json create mode 100644 browser/tests/golden/behavior/find-by-text/case_sensitive_miss.json create mode 100644 browser/tests/golden/behavior/find-by-text/clean_match_whitespace.json create mode 100644 browser/tests/golden/behavior/find-by-text/clean_trims_trailing_space.json create mode 100644 browser/tests/golden/behavior/find-by-text/exact_default.json create mode 100644 browser/tests/golden/behavior/find-by-text/first_flag.json create mode 100644 browser/tests/golden/behavior/find-by-text/no_clean_exact_ws.json create mode 100644 browser/tests/golden/behavior/find-by-text/no_clean_keeps_trailing_space.json create mode 100644 browser/tests/golden/behavior/find-by-text/none.json create mode 100644 browser/tests/golden/behavior/find-by-text/partial_case.json create mode 100644 browser/tests/golden/behavior/find-similar/anchor_missing.json create mode 100644 browser/tests/golden/behavior/find-similar/cards_attr_scoring.json create mode 100644 browser/tests/golden/behavior/find-similar/cards_high_threshold.json create mode 100644 browser/tests/golden/behavior/find-similar/list_items.json create mode 100644 browser/tests/golden/behavior/find-similar/match_text.json create mode 100644 browser/tests/golden/behavior/find-similar/match_text_multiline_leading.json create mode 100644 browser/tests/golden/behavior/find-similar/subselector_scope_cannot_escape.json create mode 100644 browser/tests/golden/behavior/find-similar/subselectors.json create mode 100644 browser/tests/golden/behavior/find/attrs_bool_coercion.json create mode 100644 browser/tests/golden/behavior/find/attrs_exact_whole_value.json create mode 100644 browser/tests/golden/behavior/find/attrs_operator_contains.json create mode 100644 browser/tests/golden/behavior/find/attrs_operator_prefix.json create mode 100644 browser/tests/golden/behavior/find/by_tag.json create mode 100644 browser/tests/golden/behavior/find/by_tag_input_attrs_map.json create mode 100644 browser/tests/golden/behavior/find/by_tag_list.json create mode 100644 browser/tests/golden/behavior/find/duplicate_and_boolean_attributes.json create mode 100644 browser/tests/golden/behavior/find/empty_text_regex_error.json create mode 100644 browser/tests/golden/behavior/find/implied_document_nodes.json create mode 100644 browser/tests/golden/behavior/find/limit_clamps.json create mode 100644 browser/tests/golden/behavior/find/negative_limit_empty.json create mode 100644 browser/tests/golden/behavior/find/no_filters_error.json create mode 100644 browser/tests/golden/behavior/find/star_tag_falls_through.json create mode 100644 browser/tests/golden/behavior/find/tag_and_text_regex.json create mode 100644 browser/tests/golden/behavior/find/tag_html_root.json create mode 100644 browser/tests/golden/behavior/find/text_regex_only_all_elements.json create mode 100644 browser/tests/golden/behavior/regex/across_fragments_edge.json create mode 100644 browser/tests/golden/behavior/regex/atomic_and_possessive.json create mode 100644 browser/tests/golden/behavior/regex/entities_edge.json create mode 100644 browser/tests/golden/behavior/regex/first_group.json create mode 100644 browser/tests/golden/behavior/regex/invalid_global_flag_position.json create mode 100644 browser/tests/golden/behavior/regex/invalid_group_reference.json create mode 100644 browser/tests/golden/behavior/regex/invalid_range.json create mode 100644 browser/tests/golden/behavior/regex/invalid_unterminated_group.json create mode 100644 browser/tests/golden/behavior/regex/invalid_variable_lookbehind.json create mode 100644 browser/tests/golden/behavior/regex/lookbehind_and_conditional.json create mode 100644 browser/tests/golden/behavior/regex/named_backreference.json create mode 100644 browser/tests/golden/behavior/regex/no_match_all.json create mode 100644 browser/tests/golden/behavior/regex/no_match_first.json create mode 100644 browser/tests/golden/behavior/regex/smoke_all.json create mode 100644 browser/tests/golden/behavior/regex/two_groups.json create mode 100644 browser/tests/golden/behavior/regex/unicode_name_escape.json create mode 100644 browser/tests/golden/behavior/regex/w3lib_html4_entities.json create mode 100644 browser/tests/golden/behavior/regex/zero_width_findall.json create mode 100644 browser/tests/golden/behavior/to-markdown/bad_format.json create mode 100644 browser/tests/golden/behavior/to-markdown/hidden_body_self_exempt.json create mode 100644 browser/tests/golden/behavior/to-markdown/html_roundtrip.json create mode 100644 browser/tests/golden/behavior/to-markdown/markdown_basic.json create mode 100644 browser/tests/golden/behavior/to-markdown/markdown_blocks_and_lists.json create mode 100644 browser/tests/golden/behavior/to-markdown/markdown_html_parser_second_parse.json create mode 100644 browser/tests/golden/behavior/to-markdown/markdown_inline_defaults.json create mode 100644 browser/tests/golden/behavior/to-markdown/markdown_table_and_video.json create mode 100644 browser/tests/golden/behavior/to-markdown/markdown_unknown_and_noise_tags.json create mode 100644 browser/tests/golden/behavior/to-markdown/pseudo_text_selector_html_mode.json create mode 100644 browser/tests/golden/behavior/to-markdown/pseudo_text_selector_text_mode.json create mode 100644 browser/tests/golden/behavior/to-markdown/scoped_css.json create mode 100644 browser/tests/golden/behavior/to-markdown/text_basic.json create mode 100644 browser/tests/golden/behavior/to-markdown/text_messy_main_only.json create mode 100644 browser/tests/golden/behavior/xpath/all_anchors_text.json create mode 100644 browser/tests/golden/behavior/xpath/ancestor_axis_reverse_position.json create mode 100644 browser/tests/golden/behavior/xpath/attr_axis_terminal.json create mode 100644 browser/tests/golden/behavior/xpath/attr_param_on_elements.json create mode 100644 browser/tests/golden/behavior/xpath/attribute_wildcard_order.json create mode 100644 browser/tests/golden/behavior/xpath/contains_href.json create mode 100644 browser/tests/golden/behavior/xpath/explicit_axis_after_slashslash.json create mode 100644 browser/tests/golden/behavior/xpath/false_scalar_becomes_empty.json create mode 100644 browser/tests/golden/behavior/xpath/first_h1.json create mode 100644 browser/tests/golden/behavior/xpath/following_axis_document_order.json create mode 100644 browser/tests/golden/behavior/xpath/global_parenthesized_position.json create mode 100644 browser/tests/golden/behavior/xpath/invalid_syntax.json create mode 100644 browser/tests/golden/behavior/xpath/number_scalar_type_error.json create mode 100644 browser/tests/golden/behavior/xpath/positional.json create mode 100644 browser/tests/golden/behavior/xpath/preceding_axis_reverse_position.json create mode 100644 browser/tests/golden/behavior/xpath/predicate_arithmetic_and_round.json create mode 100644 browser/tests/golden/behavior/xpath/predicate_attr_value.json create mode 100644 browser/tests/golden/behavior/xpath/predicate_string_functions.json create mode 100644 browser/tests/golden/behavior/xpath/string_scalar_splits_into_text_nodes.json create mode 100644 browser/tests/golden/behavior/xpath/template_child_step.json create mode 100644 browser/tests/golden/behavior/xpath/text_runs_body_messy.json create mode 100644 browser/tests/golden/behavior/xpath/text_runs_main_messy.json create mode 100644 browser/tests/golden/behavior/xpath/text_terminal.json create mode 100644 browser/tests/golden/behavior/xpath/textarea_blank_body_kept.json create mode 100644 browser/tests/golden/behavior/xpath/true_scalar_type_error.json create mode 100644 browser/tests/golden/behavior/xpath/union_doc_order.json create mode 100644 browser/tests/golden/behavior/xpath/unknown_function_error.json create mode 100644 browser/tests/golden/browser/dynamic-full-png-1.png create mode 100644 browser/tests/golden/browser/dynamic-viewport-png-1.png create mode 100644 browser/tests/golden/browser/manifest.json create mode 100644 browser/tests/golden/browser/stealthy-full-jpeg-1.jpg create mode 100644 browser/tests/golden/browser/stealthy-viewport-png-1.png create mode 100644 browser/tests/golden/schemas/browser.crawl.json create mode 100644 browser/tests/golden/schemas/browser.css.json create mode 100644 browser/tests/golden/schemas/browser.describe.json create mode 100644 browser/tests/golden/schemas/browser.dynamic-fetch.json create mode 100644 browser/tests/golden/schemas/browser.extract.json create mode 100644 browser/tests/golden/schemas/browser.fetch.json create mode 100644 browser/tests/golden/schemas/browser.find-by-regex.json create mode 100644 browser/tests/golden/schemas/browser.find-by-text.json create mode 100644 browser/tests/golden/schemas/browser.find-similar.json create mode 100644 browser/tests/golden/schemas/browser.find.json create mode 100644 browser/tests/golden/schemas/browser.regex.json create mode 100644 browser/tests/golden/schemas/browser.screenshot-url.json create mode 100644 browser/tests/golden/schemas/browser.session-close.json create mode 100644 browser/tests/golden/schemas/browser.session-fetch.json create mode 100644 browser/tests/golden/schemas/browser.session-list.json create mode 100644 browser/tests/golden/schemas/browser.session-open.json create mode 100644 browser/tests/golden/schemas/browser.stealthy-fetch.json create mode 100644 browser/tests/golden/schemas/browser.to-markdown.json create mode 100644 browser/tests/golden/schemas/browser.xpath.json create mode 100644 browser/tests/scrapling_schemas.rs create mode 100644 browser/tests/xmloxide_compat.rs create mode 100644 browser/ui/src/function-trigger-message/scrapling/CrawlView.tsx create mode 100644 browser/ui/src/function-trigger-message/scrapling/FetchView.tsx create mode 100644 browser/ui/src/function-trigger-message/scrapling/MarkdownView.tsx create mode 100644 browser/ui/src/function-trigger-message/scrapling/ParseViews.tsx create mode 100644 browser/ui/src/function-trigger-message/scrapling/ScreenshotView.tsx create mode 100644 browser/ui/src/function-trigger-message/scrapling/SearchViews.tsx create mode 100644 browser/ui/src/function-trigger-message/scrapling/SessionViews.tsx create mode 100644 browser/ui/src/function-trigger-message/scrapling/index.test.tsx create mode 100644 browser/ui/src/function-trigger-message/scrapling/index.tsx create mode 100644 browser/ui/src/function-trigger-message/scrapling/parsers.test.ts create mode 100644 browser/ui/src/function-trigger-message/scrapling/parsers.ts create mode 100644 browser/vendor/SCRAPLING-0.4.9-LICENSE create mode 100644 browser/vendor/browserforge-1.2.4/NOTICE create mode 100644 browser/vendor/browserforge-1.2.4/header-network.json create mode 100644 browser/vendor/browserforge-1.2.4/input-network.json create mode 100644 browser/vendor/cssselect/Cargo.toml create mode 100644 browser/vendor/cssselect/LICENSE create mode 100644 browser/vendor/cssselect/README.md create mode 100644 browser/vendor/cssselect/src/error.rs create mode 100644 browser/vendor/cssselect/src/html.rs create mode 100644 browser/vendor/cssselect/src/lib.rs create mode 100644 browser/vendor/cssselect/src/parser.rs create mode 100644 browser/vendor/cssselect/src/tokenizer.rs create mode 100644 browser/vendor/cssselect/src/util.rs create mode 100644 browser/vendor/cssselect/src/xpath.rs create mode 100644 browser/vendor/cssselect/tests/canonical.rs create mode 100644 browser/vendor/cssselect/tests/fixtures/html_ids.html create mode 100644 browser/vendor/cssselect/tests/fixtures/operator_precedence.xml create mode 100644 browser/vendor/cssselect/tests/fixtures/shakespeare.html create mode 100644 browser/vendor/cssselect/tests/fixtures/xmllang.xml create mode 100644 browser/vendor/cssselect/tests/html_translator.rs create mode 100644 browser/vendor/cssselect/tests/parse_errors.rs create mode 100644 browser/vendor/cssselect/tests/parser_repr.rs create mode 100644 browser/vendor/cssselect/tests/pseudo_elements.rs create mode 100644 browser/vendor/cssselect/tests/quoting.rs create mode 100644 browser/vendor/cssselect/tests/select.rs create mode 100644 browser/vendor/cssselect/tests/series.rs create mode 100644 browser/vendor/cssselect/tests/specificity.rs create mode 100644 browser/vendor/cssselect/tests/tokenizer.rs create mode 100644 browser/vendor/cssselect/tests/translation.rs create mode 100644 browser/vendor/cssselect/tests/xpath_expr.rs create mode 100644 browser/vendor/curl_impersonate_sys/.gitignore create mode 100644 browser/vendor/curl_impersonate_sys/Cargo.toml create mode 100644 browser/vendor/curl_impersonate_sys/README.md create mode 100644 browser/vendor/curl_impersonate_sys/UPSTREAM_LICENSE create mode 100644 browser/vendor/curl_impersonate_sys/artifacts.manifest create mode 100644 browser/vendor/curl_impersonate_sys/build.rs create mode 100644 browser/vendor/curl_impersonate_sys/src/lib.rs create mode 100644 browser/vendor/curl_impersonate_sys/tests/manifest.rs create mode 100644 browser/vendor/markdownify-1.2.3-beautifulsoup-4.15.0.NOTICE create mode 100644 browser/vendor/rustpython-sre_engine/Cargo.lock create mode 100644 browser/vendor/rustpython-sre_engine/Cargo.toml create mode 100644 browser/vendor/rustpython-sre_engine/Cargo.toml.orig create mode 100644 browser/vendor/rustpython-sre_engine/LICENSE create mode 100644 browser/vendor/rustpython-sre_engine/README.md create mode 100644 browser/vendor/rustpython-sre_engine/examples/differential_driver.rs create mode 100644 browser/vendor/rustpython-sre_engine/src/compiler.rs create mode 100644 browser/vendor/rustpython-sre_engine/src/constants.rs create mode 100644 browser/vendor/rustpython-sre_engine/src/engine.rs create mode 100644 browser/vendor/rustpython-sre_engine/src/lib.rs create mode 100644 browser/vendor/rustpython-sre_engine/src/string.rs create mode 100644 browser/vendor/rustpython-sre_engine/tests/differential.py create mode 100644 browser/vendor/scrapling-0.4.9-ad-domains.NOTICE create mode 100644 browser/vendor/scrapling-0.4.9-ad-domains.txt create mode 100644 browser/vendor/tld-0.13.2-psl.NOTICE create mode 100644 browser/vendor/xmloxide/Cargo.toml create mode 100644 browser/vendor/xmloxide/LICENSE create mode 100644 browser/vendor/xmloxide/README.md create mode 100644 browser/vendor/xmloxide/benches/comparison_bench.rs create mode 100644 browser/vendor/xmloxide/benches/ecosystem_bench.rs create mode 100644 browser/vendor/xmloxide/benches/parser_bench.rs create mode 100644 browser/vendor/xmloxide/examples/basic_parse.rs create mode 100644 browser/vendor/xmloxide/examples/c14n.rs create mode 100644 browser/vendor/xmloxide/examples/error_recovery.rs create mode 100644 browser/vendor/xmloxide/examples/ffi_usage.c create mode 100644 browser/vendor/xmloxide/examples/html_parse.rs create mode 100644 browser/vendor/xmloxide/examples/push_parser.rs create mode 100644 browser/vendor/xmloxide/examples/reader.rs create mode 100644 browser/vendor/xmloxide/examples/sax_streaming.rs create mode 100644 browser/vendor/xmloxide/examples/serialize.rs create mode 100644 browser/vendor/xmloxide/examples/validation.rs create mode 100644 browser/vendor/xmloxide/examples/xinclude.rs create mode 100644 browser/vendor/xmloxide/examples/xpath_query.rs create mode 100644 browser/vendor/xmloxide/include/libxml2_compat.h create mode 100644 browser/vendor/xmloxide/include/xmloxide.h create mode 100644 browser/vendor/xmloxide/src/async_xml.rs create mode 100644 browser/vendor/xmloxide/src/bin/xmllint.rs create mode 100644 browser/vendor/xmloxide/src/catalog/mod.rs create mode 100644 browser/vendor/xmloxide/src/css/eval.rs create mode 100644 browser/vendor/xmloxide/src/css/mod.rs create mode 100644 browser/vendor/xmloxide/src/css/parser.rs create mode 100644 browser/vendor/xmloxide/src/css/types.rs create mode 100644 browser/vendor/xmloxide/src/encoding/mod.rs create mode 100644 browser/vendor/xmloxide/src/error/mod.rs create mode 100644 browser/vendor/xmloxide/src/ffi/c14n.rs create mode 100644 browser/vendor/xmloxide/src/ffi/catalog.rs create mode 100644 browser/vendor/xmloxide/src/ffi/css.rs create mode 100644 browser/vendor/xmloxide/src/ffi/document.rs create mode 100644 browser/vendor/xmloxide/src/ffi/html5.rs create mode 100644 browser/vendor/xmloxide/src/ffi/mod.rs create mode 100644 browser/vendor/xmloxide/src/ffi/push.rs create mode 100644 browser/vendor/xmloxide/src/ffi/reader.rs create mode 100644 browser/vendor/xmloxide/src/ffi/sax.rs create mode 100644 browser/vendor/xmloxide/src/ffi/serial.rs create mode 100644 browser/vendor/xmloxide/src/ffi/strings.rs create mode 100644 browser/vendor/xmloxide/src/ffi/tree.rs create mode 100644 browser/vendor/xmloxide/src/ffi/validation.rs create mode 100644 browser/vendor/xmloxide/src/ffi/xinclude.rs create mode 100644 browser/vendor/xmloxide/src/ffi/xpath.rs create mode 100644 browser/vendor/xmloxide/src/html/entities.rs create mode 100644 browser/vendor/xmloxide/src/html/mod.rs create mode 100644 browser/vendor/xmloxide/src/html5/entities.rs create mode 100644 browser/vendor/xmloxide/src/html5/mod.rs create mode 100644 browser/vendor/xmloxide/src/html5/sax.rs create mode 100644 browser/vendor/xmloxide/src/html5/tokenizer.rs create mode 100644 browser/vendor/xmloxide/src/html5/tree_builder.rs create mode 100644 browser/vendor/xmloxide/src/lib.rs create mode 100644 browser/vendor/xmloxide/src/parser/input.rs create mode 100644 browser/vendor/xmloxide/src/parser/mod.rs create mode 100644 browser/vendor/xmloxide/src/parser/push.rs create mode 100644 browser/vendor/xmloxide/src/parser/xml.rs create mode 100644 browser/vendor/xmloxide/src/reader/mod.rs create mode 100644 browser/vendor/xmloxide/src/sax/mod.rs create mode 100644 browser/vendor/xmloxide/src/serde_xml/de.rs create mode 100644 browser/vendor/xmloxide/src/serde_xml/error.rs create mode 100644 browser/vendor/xmloxide/src/serde_xml/mod.rs create mode 100644 browser/vendor/xmloxide/src/serde_xml/ser.rs create mode 100644 browser/vendor/xmloxide/src/serial/c14n.rs create mode 100644 browser/vendor/xmloxide/src/serial/html.rs create mode 100644 browser/vendor/xmloxide/src/serial/mod.rs create mode 100644 browser/vendor/xmloxide/src/serial/xml.rs create mode 100644 browser/vendor/xmloxide/src/tree/mod.rs create mode 100644 browser/vendor/xmloxide/src/tree/node.rs create mode 100644 browser/vendor/xmloxide/src/util/dict.rs create mode 100644 browser/vendor/xmloxide/src/util/mod.rs create mode 100644 browser/vendor/xmloxide/src/util/qname.rs create mode 100644 browser/vendor/xmloxide/src/validation/dtd.rs create mode 100644 browser/vendor/xmloxide/src/validation/mod.rs create mode 100644 browser/vendor/xmloxide/src/validation/relaxng.rs create mode 100644 browser/vendor/xmloxide/src/validation/schematron.rs create mode 100644 browser/vendor/xmloxide/src/validation/xsd.rs create mode 100644 browser/vendor/xmloxide/src/xinclude/mod.rs create mode 100644 browser/vendor/xmloxide/src/xpath/ast.rs create mode 100644 browser/vendor/xmloxide/src/xpath/eval.rs create mode 100644 browser/vendor/xmloxide/src/xpath/lexer.rs create mode 100644 browser/vendor/xmloxide/src/xpath/mod.rs create mode 100644 browser/vendor/xmloxide/src/xpath/parser.rs create mode 100644 browser/vendor/xmloxide/src/xpath/regex.rs create mode 100644 browser/vendor/xmloxide/src/xpath/types.rs diff --git a/.github/workflows/browser-scrapling-e2e.yml b/.github/workflows/browser-scrapling-e2e.yml new file mode 100644 index 000000000..ff0c3acf1 --- /dev/null +++ b/.github/workflows/browser-scrapling-e2e.yml @@ -0,0 +1,294 @@ +name: browser scrapling E2E +run-name: Test · browser_scrapling_e2e · ${{ github.event_name }} + +on: + pull_request: + paths: + - 'browser/**' + - '.github/workflows/browser-scrapling-e2e.yml' + schedule: + - cron: '0 4 * * *' + +concurrency: + group: browser-scrapling-e2e-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + e2e: + name: Harness + if: github.event_name != 'schedule' + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.sha }} + + - name: Rewrite SSH to HTTPS for public deps + run: git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@1.97.1 + + - name: Cache cargo registry & build + uses: Swatinem/rust-cache@v2 + with: + workspaces: browser + + # browser/build.rs builds the injectable console UI (browser/ui) via + # pnpm and PANICS if pnpm is missing, so this step is required for the + # `cargo build --release --bin browser` inside run-tests.sh. Version + # comes from the repo-root package.json `packageManager` field. + - name: Setup pnpm + uses: pnpm/action-setup@v5 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: browser/tests/e2e/workers/harness/package-lock.json + + - name: Install iii engine (next) + run: | + curl -fsSL --retry 3 --retry-connrefused --retry-delay 5 \ + https://install.iii.dev/iii/main/install.sh | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Verify engine + run: iii --version + + - name: Run harness + working-directory: browser/tests/e2e + run: ./run-tests.sh + + - name: Upload report on failure + if: failure() + uses: actions/upload-artifact@v6 + with: + name: browser-scrapling-e2e-report + path: | + browser/tests/e2e/reports/ + retention-days: 7 + + tier1-compat: + name: Compat certification · ${{ matrix.target }} + if: github.event_name != 'schedule' + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + chrome: target/scrapling-chromium/x86_64-unknown-linux-gnu/chrome-linux64/chrome + - target: aarch64-unknown-linux-gnu + os: ubuntu-24.04-arm + chrome: target/scrapling-chromium/aarch64-unknown-linux-gnu/chrome-linux/chrome + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.sha }} + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@1.97.1 + with: + components: rustfmt, clippy + + - name: Setup pnpm + uses: pnpm/action-setup@v5 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: '22' + cache: 'pnpm' + cache-dependency-path: pnpm-lock.yaml + + - name: Install Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: '3.12.13' + + - name: Install uv + if: matrix.target == 'x86_64-unknown-linux-gnu' + uses: astral-sh/setup-uv@v7 + + - name: Cache cargo registry & build + uses: Swatinem/rust-cache@v2 + with: + workspaces: browser + + - name: Fetch and verify pinned artifacts + working-directory: browser + env: + BUILD_TARGET: ${{ matrix.target }} + run: | + ./scripts/fetch_curl_impersonate_artifacts.sh "$BUILD_TARGET" + ./scripts/fetch_chromium_artifacts.sh fetch "$BUILD_TARGET" + + - name: Rust formatting + working-directory: browser + run: cargo fmt --all -- --check + + - name: Strict Clippy + working-directory: browser + env: + SCRAPLING_CHROMIUM_EXECUTABLE: ${{ github.workspace }}/browser/${{ matrix.chrome }} + run: cargo clippy --all-targets --all-features --no-deps -- -D warnings + + - name: Rust tests + working-directory: browser + env: + PYTHONHASHSEED: '0' + SCRAPLING_CHROMIUM_EXECUTABLE: ${{ github.workspace }}/browser/${{ matrix.chrome }} + run: cargo test --all-targets --all-features -- --test-threads=1 + + - name: Console UI tests and build + if: matrix.target == 'x86_64-unknown-linux-gnu' + run: | + pnpm install --frozen-lockfile + pnpm --dir browser/ui test + pnpm --dir browser/ui build + + - name: Parser and regex fork checks + working-directory: browser + env: + PYTHONHASHSEED: '0' + run: | + cargo test --manifest-path vendor/cssselect/Cargo.toml --all-targets + cargo clippy --manifest-path vendor/cssselect/Cargo.toml --all-targets --no-deps -- -D warnings + cargo test --manifest-path vendor/xmloxide/Cargo.toml --all-targets + cargo clippy --manifest-path vendor/xmloxide/Cargo.toml --all-targets --no-deps -- -D warnings + cargo test --manifest-path vendor/rustpython-sre_engine/Cargo.toml --all-targets + cargo clippy --manifest-path vendor/rustpython-sre_engine/Cargo.toml --all-targets --no-deps -- -D warnings + python vendor/rustpython-sre_engine/tests/differential.py --cases 10000 --max-mismatches 30 + + - name: Public-wrapper compatibility differentials + if: matrix.target == 'x86_64-unknown-linux-gnu' + working-directory: browser + env: + PYTHONHASHSEED: '0' + run: | + uv venv --python 3.12.13 .oracle + uv pip sync --python .oracle/bin/python --require-hashes oracle/requirements.lock + .oracle/bin/python scripts/gen_goldens.py check --parser-runtime + .oracle/bin/python scripts/differential_parser.py --oracle-check parser-runtime --cases 10000 --max-mismatches 20 + .oracle/bin/python scripts/differential_http.py --oracle-check parser-runtime + + - name: Curl binding tests + working-directory: browser + run: | + cargo test --manifest-path vendor/curl_impersonate_sys/Cargo.toml --all-targets --features certified + cargo clippy --manifest-path vendor/curl_impersonate_sys/Cargo.toml --all-targets --features certified --no-deps -- -D warnings + + # Compat certification is limited to Linux x86_64/aarch64. Keep every + # other release target visible without allowing a best-effort safe compile + # failure to block the Tier-1 parity gate. + non-tier1-safe-compile: + name: Safe compile · ${{ matrix.target }} + if: github.event_name != 'schedule' + continue-on-error: true + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-apple-darwin + os: macos-latest + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-pc-windows-msvc + os: windows-latest + - target: i686-pc-windows-msvc + os: windows-latest + - target: aarch64-pc-windows-msvc + os: windows-latest + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + - target: armv7-unknown-linux-gnueabihf + os: ubuntu-22.04 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.sha }} + + - name: Setup pnpm + uses: pnpm/action-setup@v5 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: '22' + + - name: Install Linux cross compiler + if: runner.os == 'Linux' + shell: bash + env: + BUILD_TARGET: ${{ matrix.target }} + run: | + sudo apt-get update + case "$BUILD_TARGET" in + x86_64-unknown-linux-musl) + sudo apt-get install -y musl-tools + ;; + armv7-unknown-linux-gnueabihf) + sudo apt-get install -y gcc-arm-linux-gnueabihf libc6-dev-armhf-cross + ;; + esac + + - name: Install Rust target + uses: dtolnay/rust-toolchain@1.97.1 + with: + targets: ${{ matrix.target }} + + - name: Check safe build + env: + CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc + run: cargo check --manifest-path browser/Cargo.toml --target ${{ matrix.target }} + + nightly-differentials: + name: Million-case parser and regex differentials + if: github.event_name == 'schedule' + runs-on: ubuntu-latest + timeout-minutes: 180 + steps: + - uses: actions/checkout@v5 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@1.97.1 + + - name: Setup pnpm + uses: pnpm/action-setup@v5 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: '22' + + - name: Install Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: '3.12.13' + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Cache cargo registry & build + uses: Swatinem/rust-cache@v2 + with: + workspaces: browser + + - name: Run deterministic differentials + working-directory: browser + env: + PYTHONHASHSEED: '0' + run: | + uv venv --python 3.12.13 .oracle + uv pip sync --python .oracle/bin/python --require-hashes oracle/requirements.lock + .oracle/bin/python scripts/differential_parser.py --oracle-check parser-runtime --cases 1000000 --max-mismatches 20 + .oracle/bin/python vendor/rustpython-sre_engine/tests/differential.py --cases 1000000 --max-mismatches 30 diff --git a/.gitignore b/.gitignore index 1b5e129e3..08bd46973 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,8 @@ harness/config.yaml .planning/ .skill-check/ .gstack/ +.superpowers/ +.impeccable/ .worktrees/ CLAUDE.md data/ diff --git a/browser/Cargo.lock b/browser/Cargo.lock index 395a1cbe9..43a3250a5 100644 --- a/browser/Cargo.lock +++ b/browser/Cargo.lock @@ -2,6 +2,24 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +29,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "anstream" version = "1.0.0" @@ -76,6 +109,24 @@ dependencies = [ "rustversion", ] +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -110,6 +161,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64" version = "0.22.1" @@ -131,6 +188,27 @@ dependencies = [ "generic-array", ] +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "browser" version = "0.2.3" @@ -141,19 +219,47 @@ dependencies = [ "base64", "chromiumoxide", "clap", + "cssselect", + "curl_impersonate_sys", + "encoding_rs", "futures", + "html-escape", + "iii-config-client", "iii-console-ui", "iii-sdk", + "image", + "ipnet", + "jpeg-encoder", + "libc", + "psl", "regex", + "reqwest 0.12.28", + "rusqlite", + "rustpython-sre_engine", "schemars", "serde", "serde_json", "serde_yaml", + "siphasher", "tokio", + "tokio-tungstenite", "tracing", "tracing-subscriber", "url", + "uuid", "which", + "xmloxide", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", ] [[package]] @@ -162,6 +268,18 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -178,6 +296,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -315,6 +435,55 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -349,6 +518,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -359,12 +537,30 @@ dependencies = [ "typenum", ] +[[package]] +name = "cssselect" +version = "0.2.0" +dependencies = [ + "sxd-document", + "sxd-xpath", +] + +[[package]] +name = "curl_impersonate_sys" +version = "0.1.0" + [[package]] name = "data-encoding" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.10.7" @@ -386,6 +582,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dunce" version = "1.0.5" @@ -404,6 +609,15 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -420,12 +634,43 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -584,12 +829,30 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -607,6 +870,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "html-escape" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5" + [[package]] name = "http" version = "1.4.2" @@ -808,6 +1077,18 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "iii-config-client" +version = "0.1.0" +dependencies = [ + "iii-sdk", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "iii-console-ui" version = "0.1.0" @@ -862,6 +1143,21 @@ dependencies = [ "uuid", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -869,7 +1165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -884,12 +1180,37 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "jpeg-encoder" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0370574b86f7eca156b9f298392b5e69a23f8c86f3f865add60bbc2e79467a6" + [[package]] name = "js-sys" version = "0.3.103" @@ -913,12 +1234,29 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "litemap" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "log" version = "0.4.33" @@ -946,6 +1284,22 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -957,6 +1311,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -975,6 +1339,42 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -1056,18 +1456,87 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "optional" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978aa494585d3ca4ad74929863093e87cac9790d81fe7aba2b3dc2890643a0fc" + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "peresil" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f658886ed52e196e850cfbbfddab9eaa7f6d90dd0929e264c31e5cec07e09e57" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.7", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1077,6 +1546,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1095,6 +1570,43 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl" +version = "2.1.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e6f9add6bc6dff9f3627544800ee38967ab62d1fe7d4148e99ac5f93e9501a" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quinn" version = "0.11.11" @@ -1172,13 +1684,24 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -1193,6 +1716,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -1203,6 +1736,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rand_core" version = "0.9.5" @@ -1241,9 +1783,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.15" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1264,6 +1806,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "cookie", + "cookie_store", + "encoding_rs", "futures-core", "http", "http-body", @@ -1273,6 +1818,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime", "percent-encoding", "pin-project-lite", "quinn", @@ -1337,6 +1883,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -1390,6 +1950,30 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustpython-sre_engine" +version = "0.5.0" +dependencies = [ + "bitflags", + "num_enum", + "optional", + "rustpython-wtf8", + "unicode-ident", + "unicode_names2", +] + +[[package]] +name = "rustpython-wtf8" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada88d2f69ff5516d69e0f3294e9db2ff8ee71a15291b8f3f8584f07ad1ca28d" +dependencies = [ + "ascii", + "bstr", + "itertools", + "memchr", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -1505,6 +2089,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -1573,6 +2158,18 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -1613,6 +2210,27 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "sxd-document" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94d82f37be9faf1b10a82c4bd492b74f698e40082f0f40de38ab275f31d42078" +dependencies = [ + "peresil", + "typed-arena", +] + +[[package]] +name = "sxd-xpath" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36e39da5d30887b5690e29de4c5ebb8ddff64ebd9933f98a01daaa4fd11b36ea" +dependencies = [ + "peresil", + "quick-error", + "sxd-document", +] + [[package]] name = "syn" version = "2.0.118" @@ -1687,6 +2305,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1776,6 +2424,19 @@ dependencies = [ "tungstenite", ] +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" @@ -1797,12 +2458,17 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ + "async-compression", "bitflags", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -1907,6 +2573,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "typed-arena" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b2228007eba4120145f785df0f6c92ea538f5a3635a612ecf4e334c8c1446d" + [[package]] name = "typenum" version = "1.20.1" @@ -1919,6 +2591,26 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode_names2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d189085656ca1203291e965444e7f6a2723fbdd1dd9f34f8482e79bafd8338a0" +dependencies = [ + "phf", + "unicode_names2_generator", +] + +[[package]] +name = "unicode_names2_generator" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1262662dc96937c71115228ce2e1d30f41db71a7a45d3459e98783ef94052214" +dependencies = [ + "phf_codegen", + "rand 0.8.7", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -1979,6 +2671,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -2339,6 +3037,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xmloxide" +version = "0.5.0" +dependencies = [ + "encoding_rs", + "serde", + "tokio", +] + [[package]] name = "yoke" version = "0.8.3" @@ -2447,3 +3154,46 @@ name = "zmij" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd2f034a4bebf216c9e4b7083603e024cf930873fd67830cfb083c9fa33129d9" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/browser/Cargo.toml b/browser/Cargo.toml index b87d44cd5..af0ff7a89 100644 --- a/browser/Cargo.toml +++ b/browser/Cargo.toml @@ -1,4 +1,6 @@ [workspace] +members = ["."] +exclude = ["vendor/cssselect", "vendor/curl_impersonate_sys", "vendor/rustpython-sre_engine", "vendor/xmloxide"] [package] name = "browser" @@ -6,6 +8,10 @@ version = "0.2.3" edition = "2021" publish = false +[features] +default = [] +scrapling-compat = ["dep:curl_impersonate_sys", "curl_impersonate_sys/certified"] + [[bin]] name = "browser" path = "src/main.rs" @@ -16,11 +22,15 @@ path = "src/lib.rs" [dependencies] iii-sdk = "=0.21.6" +iii-config-client = { path = "../crates/config-client" } iii-console-ui = { path = "../crates/console-ui" } arc-swap = "1" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time", "process"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time", "process", "net"] } serde = { version = "1", features = ["derive"] } -serde_json = "1" +# preserve_order: the scrapling goldens are byte-compared against +# Python-generated schemas, so JSON object key order is load-bearing +# (tests/behavior.rs also relies on Map::shift_remove, IndexMap-only). +serde_json = { version = "1", features = ["preserve_order"] } serde_yaml = "0.9" anyhow = "1" tracing = "0.1" @@ -34,6 +44,33 @@ url = "2" regex = "1" chromiumoxide = "0.9" +cssselect = { path = "vendor/cssselect" } +xmloxide = { path = "vendor/xmloxide", default-features = false } +rustpython-sre_engine = { path = "vendor/rustpython-sre_engine" } +curl_impersonate_sys = { path = "vendor/curl_impersonate_sys", optional = true } +html-escape = "0.2" +encoding_rs = "0.8" +psl = "=2.1.180" +rusqlite = { version = "=0.31.0", features = ["bundled"] } +uuid = { version = "1", features = ["v4"] } +libc = "0.2" +tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect", "rustls-tls-native-roots"] } +# Safe-mode browser::fetch uses this bounded rustls client. Certified compat +# builds route the same public function through the pinned curl-impersonate +# archive above. +# `charset` is load-bearing, not optional: without it `Response::text()` is a +# lossy UTF-8 decode, so a Shift-JIS or ISO-8859-1 page would come back as +# replacement characters WHILE the envelope reported its real encoding. The +# compression features matter for the same reason — a server that gzips +# unasked would otherwise be decoded as binary garbage. +reqwest = { version = "0.12", default-features = false, features = [ + "rustls-tls", "json", "cookies", "socks", "charset", "gzip", "brotli", "deflate", "zstd", +] } +ipnet = "2" +image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } +jpeg-encoder = "0.7.1" +siphasher = "1.0.3" + [dev-dependencies] serde_json = "1" which = "8" diff --git a/browser/README.md b/browser/README.md index fe8cfe078..778319533 100644 --- a/browser/README.md +++ b/browser/README.md @@ -10,6 +10,12 @@ worker adds the human window: a live Browser page with a streaming viewport (Chromium-pushed screencast frames), the console feed, and click-to-pick elements into chat. +It also carries a native Rust scraping surface, `browser::*`: HTTP +and browser fetching, screenshots, persistent sessions and BFS crawling, plus +CSS/XPath/regex queries, element search and HTML→Markdown that run over any +HTML string with no browser at all. See +[Scraping and HTML parsing](#scraping-and-html-parsing-browser) below. + ## In the console An agent reads a page as an accessibility outline (`browser::snapshot`) while @@ -138,11 +144,184 @@ both are fixed-position in-page overlays that never touch page content. `browser::doctor` reports whether ffmpeg (recording) and attach mode are available. +## Scraping and HTML parsing (`browser::*`) + +The worker also ships a native Rust port of the [scrapling](https://github.com/D4Vinci/Scrapling) +worker's surface: 19 functions covering HTTP and browser fetching, screenshots, +persistent sessions, crawling, and — the part that needs no browser at all — +parsing HTML you already have. + +Start with the parse functions: they work on any HTML string with no browser +or network. Adaptive CSS/XPath/extract calls are the exception to statelessness: +they persist relocation identities in the configured SQLite database. They pair naturally with the session functions above +(navigate, read the page, then parse it), but they don't need one. + +```bash +iii trigger browser::css --payload '{ + "html": "", + "query": "a.product", + "attr": "href", + "first": true +}' +# → { "result": "/sku/1" } +``` + +`first` defaults to `false`, in which case `result` is an array of every match +instead of just the first. + +```bash +iii trigger browser::extract --payload '{ + "html": "

Widget

$19.99buy
", + "selectors": [ + { "name": "title", "css": "h3" }, + { "name": "price", "css": ".price" }, + { "name": "url", "css": "a", "attr": "href" } + ] +}' +# → { "extracted": { "title": "Widget", "price": "$19.99", "url": "/sku/1" } } +``` + +The 10 parse functions: `extract`, `css`, `xpath`, `regex`, `find`, +`find-by-text`, `find-by-regex`, `find-similar`, `describe`, `to-markdown`. +Non-adaptive parsing has no operator-tunable defaults. The fixed limit, +`find` / `find-by-text` / `find-by-regex` capping +at 100 items per call (`limit` clamps to `[0, 100]`), mirrors the python +worker's hardcoded cap. + +### Fetching, sessions and crawl + +Nine more functions go out to the network. They share one response envelope — +`{status, url, headers, cookies, encoding}` plus, on request, `extracted` +(from `selectors`), `content`+`format` (`markdown`/`text`) and `html` — so the +parse layer above is reachable inline, without a second call. + +Three fetch tiers, cheapest first; escalate only when the cheaper one fails: + +| | engine | use when | +|---|---|---| +| `fetch` | safe: reqwest/rustls; compat: frozen curl-impersonate | static pages, APIs — no browser, fastest | +| `dynamic-fetch` | frozen Chrome over raw CDP | the page needs JavaScript to render | +| `stealthy-fetch` | frozen Chrome with the Patchright command/launch sequence | the site sniffs for automation | + +```bash +iii trigger browser::fetch --json '{ + "url": "https://example.com/", + "selectors": [{ "name": "title", "css": "h1" }], + "format": "text" +}' +# → { "status": 200, "url": "...", "extracted": { "title": "Example Domain" }, ... } +``` + +All three take a single `url` or a bulk `urls` list (bulk returns +`{results: [...]}`, where a failed URL contributes `{url, error}` instead of +sinking the batch). `dynamic-fetch` and `stealthy-fetch` additionally accept +`wait_selector` (+ `wait_selector_state`), `network_idle`, and `wait`. + +`browser::screenshot-url` captures a page as image content blocks the console renders +inline — downscaled to 1024px wide and split into at most six 1536px tiles, +with the caption saying so when a page is taller than that. + +`session-open` / `session-fetch` / `session-close` / `session-list` keep state +in a private Scrapling registry. HTTP sessions retain one cookie jar/transport; +dynamic and stealthy sessions retain one browser process and context. All use +UUID4 hex ids and serialize requests FIFO per session. They never appear in +`browser::sessions::list`, and interactive ids are not accepted. One-shot +browser calls get a fresh process/profile; retries get a fresh page in that +process. Compat mode supports request proxies, remote `cdp_url`, and +`solve_cloudflare` on stealthy calls. + +`crawl` walks links breadth-first from `start_urls`, extracting per page. It +stays on the seed domain by default (`www.` folded), strips URL fragments when +deduping, and stops at `max_pages` (20) or `max_depth` (2). Every page is +emitted on a stream; the RPC response carries only a ≤10-item sample plus the +stream name and group id to read the rest with `stream::on`. + +**These functions take a caller-supplied URL, so they are an SSRF surface.** +Safe mode rejects caller proxies and checks every connection against private, +loopback, link-local (including cloud metadata), CGNAT, multicast and reserved +ranges. Set `browser.scrapling.allow_loopback: true` to scrape a local dev +server; every other private range stays blocked. Compat mode intentionally +reproduces the standalone worker's unrestricted network behavior and should be +enabled only for trusted calls. All nine functions remain at the +`needs_approval` default in `iii-permissions.yaml`, unlike the ten parse +functions. + +The guarantee differs by tier, and the difference is worth knowing: + +- **`fetch` (HTTP) — checked before every hop.** Redirects are followed by + hand precisely so each hop is validated *before* the request is made, and + each connection is pinned to the address that was validated, closing the DNS + rebinding window between check and connect. `Authorization` and `Cookie` are + dropped on a cross-origin redirect, as curl has done since CVE-2018-1000007. +- **Browser tiers — checked at the socket boundary.** Safe-mode Chrome is + forced through an in-process HTTP/CONNECT gate. The gate resolves, checks, + and pins every destination before dialing, including redirect destinations; + direct bypass, QUIC and WebRTC are disabled. + +Two more safe-mode limits worth stating: response bodies are bounded at 32 MiB +whether or not the server declares a content length, and a `fetch` call is +capped at three times its `timeout` in total. Compat mode preserves the frozen +worker's unbounded response and retry/redirect quirks. + +### Compatibility modes and certification + +Request/response schemas are golden-pinned to the frozen Python wrapper apart +from provider-id mapping. Native calls use `browser::`; Python keeps +`scrapling::`. Python `scrapling::screenshot` maps to native +`browser::screenshot-url`, while `browser::screenshot` remains the interactive +session screenshot. Crawl streams default to `browser::crawl`. + +`security_mode: safe` is the default. It keeps SSRF checks and resource +ceilings, refuses network options the safe engine cannot enforce, rejects +`verify: false`, and bounds adaptive storage. `security_mode: compat` is only +eligible on Tier-1 Linux x86_64/aarch64 builds produced with the certified +curl-impersonate and Chromium artifacts. Other targets reject compat instead +of silently degrading. Eligibility is not a claim that an arbitrary local +build is certified: builds without the frozen artifacts return a capability +error, and callers should keep using safe mode or the standalone worker. + +The parser/query core, CSS-to-XPath translation, XPath 1.0 evaluation, Python +regex behavior, Markdown conversion, selector generation, and adaptive +relocation are repository-owned compatibility implementations covered by +exact differential fixtures. Adaptive queries persist element identities in +SQLite at `adaptive_storage_path`; parse functions remain auto-allowed, so +operators should treat that path as durable worker state. Safe mode enforces +`adaptive_max_bytes` (256 MiB by default) and rolls back a write that would +exceed it. Compat mode keeps the frozen worker's unbounded behavior. + +Safe HTTP uses the bounded native engine. Compat HTTP is linked to the frozen +curl-impersonate archive; compat browser calls use the certified Chrome build +through raw pipe/WebSocket CDP and reproduce the frozen Playwright/Patchright +sequences. Persistent browser sessions, proxy rotation, remote CDP, +Cloudflare handling and screenshot transforms use that same private runtime. +Certified builds fail when pinned artifacts are absent or mismatched; there is +no silent fallback from compat to safe. + +The standalone worker remains the oracle and production fallback during +rollout. Migrate calls to `browser::` (with screenshot mapped to +`browser::screenshot-url`) only after draining its sessions, then compare both +providers through one stable release and at least 30 days without an +untriaged mismatch. Removing the standalone worker is a separate change. + +### Regenerating the parse goldens + +`tests/golden/schemas/browser.*.json` and `tests/golden/behavior/**` +are written **only** by `scripts/gen_goldens.py`, run against the reference +Python implementation — never by `UPDATE_GOLDENS=1`, so a passing test always +means "Rust still agrees with Python": + +```bash +~/.iii/managed/scrapling/usr/local/bin/python3.12 scripts/gen_goldens.py schemas +~/.iii/managed/scrapling/usr/local/bin/python3.12 scripts/gen_goldens.py behavior +``` + ## Configuration -Stored in the `configuration` worker under the `browser` key; every field is -editable live from the console. Caps and timeouts hot-reload; `executable`, -`headless`, and the viewport apply to sessions started after the change. +Stored in the `configuration` worker under the `browser` key. Existing +interactive-browser settings retain their current behavior. Scrapling settings +live in an isolated nested block: bulk/default policy can be read per call, +while the session cap, idle timeout, and adaptive database path are snapshotted +at worker startup. Restart after changing a startup-snapshotted value. ```yaml browser: @@ -161,6 +340,24 @@ browser: allowed_schemes: [http, https, file] # `file` lets a local document be rendered; see below max_snapshot_nodes: 2000 # a11y outline size cap allow_attach: false # true = allow sessions::attach into a running browser's real profile + + scrapling: + security_mode: safe # safe | compat; compat is Tier-1 certified builds only + chromium_executable: '' # certified Chrome path; empty = discovery + allow_loopback: false # true = permit 127.0.0.1 / ::1 in outbound calls + + defaults: + impersonate: chrome + headless: true + network_idle: false + proxy: '' + include_html: false + + max_bulk_concurrency: 5 + max_sessions: 8 + session_idle_timeout_s: 900 + adaptive_storage_path: ./data/scrapling/elements.db + adaptive_max_bytes: 268435456 # safe only; compat preserves unbounded oracle behavior ``` `file` is on the default scheme list so a local document can be opened and @@ -170,6 +367,14 @@ filesystem scope the way the workers that read files directly are, so anything that can reach `browser::navigate` can open any file this process can read. Narrow the list on a shared machine. +The compatibility fields are part of the stable configuration surface. +Non-Tier-1 or artifact-free builds retain safe mode and reject compat +explicitly instead of approximating it. + +The declared production envelope is 4 GiB memory and 2 CPUs. Tier-1 release +validation budgets for five concurrent browser processes; that is a release +test envelope, not permission to exceed configured session caps. + ## Custom trigger types Sibling workers (and the console UI) can subscribe to session activity. All diff --git a/browser/examples/scrapling_differential.rs b/browser/examples/scrapling_differential.rs new file mode 100644 index 000000000..153f081ad --- /dev/null +++ b/browser/examples/scrapling_differential.rs @@ -0,0 +1,21 @@ +use std::io::{self, BufRead, Write}; + +use serde_json::{json, Value}; + +fn main() -> Result<(), Box> { + let stdin = io::stdin(); + let mut stdout = io::BufWriter::new(io::stdout().lock()); + for line in stdin.lock().lines() { + let request: Value = serde_json::from_str(&line?)?; + let function = request["function"].as_str().ok_or("missing function")?; + let payload = request.get("payload").ok_or("missing payload")?; + let response = match browser::scrapling::dispatch_op(function, payload) { + Ok(value) => json!({"ok": value}), + Err(error) => json!({"err": error}), + }; + serde_json::to_writer(&mut stdout, &response)?; + stdout.write_all(b"\n")?; + stdout.flush()?; + } + Ok(()) +} diff --git a/browser/examples/scrapling_http_differential.rs b/browser/examples/scrapling_http_differential.rs new file mode 100644 index 000000000..0a867de11 --- /dev/null +++ b/browser/examples/scrapling_http_differential.rs @@ -0,0 +1,34 @@ +use std::io::{self, BufRead, Write}; +use std::sync::Arc; + +use browser::config::{SecurityMode, WorkerConfig}; +use browser::scrapling::net::{self, Ctx}; +use browser::scrapling::sessions::Registry; +use serde_json::{json, Value}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut config = WorkerConfig::default(); + config.scrapling.security_mode = SecurityMode::Compat; + config.scrapling.allow_loopback = true; + let ctx = Ctx { + http: Registry::new(8, 900), + config: config.into_shared(), + iii: Arc::new(iii_sdk::IIIClient::new("ws://127.0.0.1:0")), + }; + let stdin = io::stdin(); + let mut stdout = io::BufWriter::new(io::stdout().lock()); + for line in stdin.lock().lines() { + let request: Value = serde_json::from_str(&line?)?; + let function = request["function"].as_str().ok_or("missing function")?; + let payload = request.get("payload").ok_or("missing payload")?; + let response = match net::dispatch(&ctx, function, payload).await { + Ok(value) => json!({"ok": value}), + Err(error) => json!({"err": error}), + }; + serde_json::to_writer(&mut stdout, &response)?; + stdout.write_all(b"\n")?; + stdout.flush()?; + } + Ok(()) +} diff --git a/browser/iii-permissions.yaml b/browser/iii-permissions.yaml new file mode 100644 index 000000000..32262d9c8 --- /dev/null +++ b/browser/iii-permissions.yaml @@ -0,0 +1,24 @@ +# Agent permissions for the browser worker's scrapling parse surface. +# Spec: docs/sops/new-worker.md § 7. First-match-wins. +# +# Every function listed here operates only on caller-provided HTML and uses no +# network or browser, so it is auto-allowed like the Python parse surface. +# Adaptive CSS/XPath/extract calls can persist identities in the configured +# SQLite database; operators should size and protect that durable state. +# The interactive browser::* surface is deliberately absent: it +# drives a real browser and stays at the needs_approval default, as do the +# outbound-fetching browser::{fetch,dynamic-fetch,stealthy-fetch,screenshot-url, +# session-*,crawl} functions (an SSRF surface). +version: 1 + +rules: + - browser::extract + - browser::css + - browser::xpath + - browser::regex + - browser::find-similar + - browser::find + - browser::find-by-text + - browser::find-by-regex + - browser::describe + - browser::to-markdown diff --git a/browser/iii.worker.yaml b/browser/iii.worker.yaml index cfbc4a3cb..41a0218b5 100644 --- a/browser/iii.worker.yaml +++ b/browser/iii.worker.yaml @@ -5,5 +5,11 @@ deploy: binary manifest: Cargo.toml license: Apache-2.0 bin: browser -tags: [browser, chromium, automation, web, cdp] -description: Interactive Chromium sessions on the iii bus. Navigate, act, read the page console, pick elements. +tags: [browser, chromium, automation, web, cdp, scraping, extraction, parsing] +description: Interactive Chromium sessions on the iii bus. Navigate, act, read the page console, pick elements. Also parses HTML natively without a browser (browser::* — css/xpath/regex, element search, markdown). + +# Browser compatibility validation budgets for five concurrent Chromium +# processes inside this production envelope. Runtime session caps still apply. +resources: + memory: 4096 + cpus: 2 diff --git a/browser/oracle/README.md b/browser/oracle/README.md new file mode 100644 index 000000000..742b7c9c5 --- /dev/null +++ b/browser/oracle/README.md @@ -0,0 +1,48 @@ +# Frozen Scrapling oracle + +`requirements.lock` is the hashed Python 3.12 resolution used to capture the +standalone worker's observable contract. Regenerate it with: + +```sh +uv pip compile oracle/requirements.in \ + --python-version 3.12 \ + --python-platform linux \ + --generate-hashes \ + --output-file oracle/requirements.lock \ + --custom-compile-command 'scripts/update_oracle.sh' +``` + +`manifest.json` records the worker source, runtime, browser, and host data that +can change observable output. Build and verify the oracle with: + +```sh +uv venv --python 3.12.13 .oracle +uv pip sync --python .oracle/bin/python --require-hashes oracle/requirements.lock +PYTHONHASHSEED=0 .oracle/bin/python scripts/verify_oracle.py +``` + +`scripts/gen_goldens.py` runs that verification before writing anything. A +release verification also passes `--archive-dir DIR`; `DIR` must contain the +six archive filenames recorded in `manifest.json`. `--write` is reserved for +an intentional oracle refresh and also requires the archive directory. + +Pull-request differentials install the same hashed lock into a fresh virtual +environment and use `verify_oracle.py --parser-runtime`. That mode still hashes +the standalone source, every immutable package file, and every parser data +asset; it excludes only browser archives and host-specific executable, font, +locale, timezone, and CA-bundle records. Run the public-wrapper comparators with: + +```sh +PYTHONHASHSEED=0 .oracle/bin/python scripts/differential_parser.py --oracle-check parser-runtime --cases 10000 +PYTHONHASHSEED=0 .oracle/bin/python scripts/differential_http.py --oracle-check parser-runtime +``` + +CI also regenerates every schema and behavior fixture in a temporary directory +and byte-compares it with the committed goldens: + +```sh +PYTHONHASHSEED=0 .oracle/bin/python scripts/gen_goldens.py check --parser-runtime +``` + +The scheduled certification job raises each HTML/CSS/XPath and regex grammar +to one million deterministic cases. diff --git a/browser/oracle/manifest.json b/browser/oracle/manifest.json new file mode 100644 index 000000000..a4592e96f --- /dev/null +++ b/browser/oracle/manifest.json @@ -0,0 +1,12469 @@ +{ + "format": 1, + "source": { + "version": "0.2.6", + "sha256": "aed73077a9bfe523f842e8e5133f0d9a941dbb6b9862dae6c0f7bb1c27e12c1f", + "files": [ + { + "path": "scrapling/.gitignore", + "size": 116, + "sha256": "2d60003e5d625fbfed94267283237d378c1c36f9dda0013bac8434304493f497" + }, + { + "path": "scrapling/README.md", + "size": 4749, + "sha256": "f98ddca029ca09fd6046239b2b8c2abeb515f2f607ebab632d1e2a6829c07d7b" + }, + { + "path": "scrapling/config.yaml", + "size": 1387, + "sha256": "b5cb08c7bef71d2b9dc8949b2565adfe7e7d76ad934ebbe955fdf238c4e7c648" + }, + { + "path": "scrapling/iii-permissions.yaml", + "size": 692, + "sha256": "d8d2639f0b89b7fbd40affe5fd56721d4699d3aeb80883ab13cdc2b90d208902" + }, + { + "path": "scrapling/iii.worker.yaml", + "size": 1787, + "sha256": "7266df502cb6e4f4a6cea26fd5fe91641a0795d73e590ba997f5b17f948ec2ae" + }, + { + "path": "scrapling/pyproject.toml", + "size": 1154, + "sha256": "cd9e0c0eda2cd3f53811706aff2a66c63d439f81a3089b6524a2aed06c2ed41b" + }, + { + "path": "scrapling/skills/SKILL.md", + "size": 3461, + "sha256": "f4bdd7922233e2557d246cfb7ccfe454ce52f80d4b3c7507c39e0a113749f19c" + }, + { + "path": "scrapling/src/__init__.py", + "size": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "path": "scrapling/src/core.py", + "size": 20102, + "sha256": "cee7939b01e213af2c554faf6963b8b0db73ddd24b5b672e585aeb934584398e" + }, + { + "path": "scrapling/src/crawl.py", + "size": 7559, + "sha256": "bd43538b0dee12374af7ab68f3ad166310bf29b0d3d55f0de5c5815e8e968e75" + }, + { + "path": "scrapling/src/guidance.py", + "size": 6492, + "sha256": "e010648370f6ca1858a5e9292d943cdbcd1578c86fac6bb2bc6175675d3d4764" + }, + { + "path": "scrapling/src/handlers.py", + "size": 5515, + "sha256": "ce6500dcd2a5e5dbfb26bb3b7c58f9349d42d5814f58c9e479d2df317eb0c80f" + }, + { + "path": "scrapling/src/main.py", + "size": 2700, + "sha256": "e787274476a69635815340342f3015c93d4c4698c060e6d8df9de0295bde126d" + }, + { + "path": "scrapling/src/schemas.py", + "size": 19648, + "sha256": "623202d3bdb1fe6b9894688d12ba073af00759f14b58468c00f8d3b943fcc2d9" + }, + { + "path": "scrapling/src/sessions.py", + "size": 9198, + "sha256": "f2f32687e6834c93d0b3dbf9f43aed49b4a11787efbb8e0deb29020f992e8f20" + }, + { + "path": "scrapling/src/storage.py", + "size": 1454, + "sha256": "aaf5d94ca150f46f35154107f9c05602faf25ffb37417c0f210a742e66469615" + }, + { + "path": "scrapling/tests/__init__.py", + "size": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "path": "scrapling/tests/test_adaptive.py", + "size": 2259, + "sha256": "c8e8cbf0fd7a8754ddbd10daeca0f91f9c1916856bffa95a9c3d5ee71cf0c027" + }, + { + "path": "scrapling/tests/test_crawl.py", + "size": 8406, + "sha256": "b67ff5cacc07ed194a7a2cc386acf3f55f5e8d39218f8dcddc2650a84d5fcb26" + }, + { + "path": "scrapling/tests/test_extract.py", + "size": 5605, + "sha256": "e79811b374f3735242f0247777a177f8b4ad60040551e14936c9221941a6028a" + }, + { + "path": "scrapling/tests/test_fetch.py", + "size": 5697, + "sha256": "1d77ce5a317301372c893d5fcd8f78c34daf5bf83715b9e3c1e9bd1bc0de04a3" + }, + { + "path": "scrapling/tests/test_guidance.py", + "size": 2798, + "sha256": "cbdda7cd12b234744b1a157bc4ce5f21913411d033399109c805c174b1e33ad4" + }, + { + "path": "scrapling/tests/test_main.py", + "size": 336, + "sha256": "d95bea62d9bbabab72510dbb1348750c8d4a7740e67fd094e1079748e7b9657e" + }, + { + "path": "scrapling/tests/test_register.py", + "size": 1530, + "sha256": "dd01197b19bffcd739c5e2916ff3dbb0c61bae00e1f626ebe4bb662116d616f2" + }, + { + "path": "scrapling/tests/test_screenshot.py", + "size": 3559, + "sha256": "3120eded718159d5d767a029fd1e89b2953585e917ba00ef4eabd19784652edf" + }, + { + "path": "scrapling/tests/test_sessions.py", + "size": 5907, + "sha256": "8bddd0427b1544e2495c2e8bb0388933cdcc13aa13a7d52fe147fc6f81c07c88" + }, + { + "path": "scrapling/vendor/iii_helpers-0.21.4-py3-none-any.whl", + "size": 27438, + "sha256": "7ba233214ef45df3bc5e5e2644c2f9a30a919848ec5bf558da4ecb6d74dfbbe4" + }, + { + "path": "scrapling/vendor/iii_sdk-0.21.4-py3-none-any.whl", + "size": 36912, + "sha256": "e1a3334b6e92c65e45e82b453cca8e71baba32817a9914505c6034b0a34d8b3b" + } + ] + }, + "python": { + "version": "3.12.13", + "implementation": "cpython", + "executable": { + "path": "/home/anderson/.iii/managed/scrapling/usr/local/bin/python3.12", + "size": 14472, + "sha256": "0e6475dfda68a9b2d93501449fc47593ca169010e8f4881577b97463fd0c1263" + }, + "parser_runtime_sha256": "7f34b7199256f159ddb1cdccdcd958b60d13f73d6406941a89089ed659518c9a", + "packages": [ + { + "name": "annotated-types", + "version": "0.8.0", + "files": 9, + "bytes": 36539, + "sha256": "2161fb242ce4891e03a7af394d4cb16a900e9de343b8896f646520c6c9e7530f" + }, + { + "name": "anyio", + "version": "4.14.2", + "files": 54, + "bytes": 512582, + "sha256": "ca148f5302cf7ee32149725310cef0e7f2715757a7ec9dbdc47dc4a33b23f6b2" + }, + { + "name": "apify-fingerprint-datapoints", + "version": "0.15.0", + "files": 15, + "bytes": 789878, + "sha256": "55968373215de519e13701a6d7704d7415dea6267d528e917bb17eddb8782ea3" + }, + { + "name": "beautifulsoup4", + "version": "4.15.0", + "files": 23, + "bytes": 406683, + "sha256": "89a623ae25079adfddc21be7af4863525579fe6e798e7ff0629420b3b178b20f" + }, + { + "name": "browserforge", + "version": "1.2.4", + "files": 25, + "bytes": 111453, + "sha256": "0dfd86fd996459cef3631a8642d023f06e6cf5e7448d83f86bcf7cceefbf91ab" + }, + { + "name": "certifi", + "version": "2026.7.22", + "files": 14, + "bytes": 249105, + "sha256": "0293fbee6d70cd316b912e4805bc1d1ac7a4c2e1abc91a1005499b6cb3a6ef58" + }, + { + "name": "cffi", + "version": "2.1.1", + "files": 34, + "bytes": 735720, + "sha256": "e6dcffafa6552dbfe84144ee37541516316768b9d380107755b3deae8384af42" + }, + { + "name": "click", + "version": "8.4.2", + "files": 24, + "bytes": 428667, + "sha256": "8c5354f6e36a00f2397de8b52823858e0609ba0845de21f3065273b2822a581c" + }, + { + "name": "cssselect", + "version": "1.5.0", + "files": 11, + "bytes": 79070, + "sha256": "bf1aedc12d25da70a9fd7c632b3770dbc75b0e4c6088f2df1cb78b754321b221" + }, + { + "name": "curl-cffi", + "version": "0.16.0", + "files": 39, + "bytes": 38908378, + "sha256": "d0fe62919fdf80b4a75fb82052795b9ad50b8fd63a9e3055fe53ad3f8909b7e6" + }, + { + "name": "greenlet", + "version": "3.5.5", + "files": 101, + "bytes": 2052676, + "sha256": "12f8f5320458fc4501447fe6da37a161e42aa6afd7d5c9085b12e224272a1f6b" + }, + { + "name": "h11", + "version": "0.16.0", + "files": 19, + "bytes": 104105, + "sha256": "94ea26c66a3a1adff0b33f55c1e5fa55cbd32483a00b970cf02d77d4de7a0dc7" + }, + { + "name": "httpcore", + "version": "1.0.9", + "files": 38, + "bytes": 288194, + "sha256": "3a78b867684f52db6438aecc1099b1f6c86667a800f39f8c065f902f85c5e3fe" + }, + { + "name": "httpx", + "version": "0.28.1", + "files": 32, + "bytes": 295871, + "sha256": "3bfff9ebaf65bf5e227f79aa4c1635c5fd3d78787d800e805a3677a308d94d9c" + }, + { + "name": "idna", + "version": "3.18", + "files": 19, + "bytes": 326740, + "sha256": "829d7302265f56e364f2686092f2a184a8f1aaceae4bb2bab4407582a8f981f9" + }, + { + "name": "iii-helpers", + "version": "0.21.6", + "files": 21, + "bytes": 85105, + "sha256": "641ff05cd75034d104d23502e7a0ccba28677a2d84a423aa3b90573621829a1f" + }, + { + "name": "iii-sdk", + "version": "0.21.6", + "files": 26, + "bytes": 122922, + "sha256": "bcadec4d7c03800a3bec4b1e145b81abee922e86b147cfd39a805b553f779127" + }, + { + "name": "lxml", + "version": "6.1.1", + "files": 176, + "bytes": 11715954, + "sha256": "ffafacde7d694071bdf5a7fec08d93215c88143f73828056bd55ef6c8ba4b03f" + }, + { + "name": "markdownify", + "version": "1.2.3", + "files": 13, + "bytes": 49657, + "sha256": "e9e6f13fa7cc03537bfc8387423d4c1e9d88d8ad8561d0843a9b29c442f74587" + }, + { + "name": "msgspec", + "version": "0.21.1", + "files": 25, + "bytes": 526848, + "sha256": "b40d8bf690c8f7c87bf82383005019541202ee3819baccbcf0bfa18ab7197c9b" + }, + { + "name": "opentelemetry-api", + "version": "1.44.0", + "files": 49, + "bytes": 197984, + "sha256": "1d3d52c6c6b6611d658438f07d46461d5a839f9b8596169fdfef97465a824623" + }, + { + "name": "opentelemetry-sdk", + "version": "1.44.0", + "files": 72, + "bytes": 515548, + "sha256": "ef85ac93d3cebd4c7d5310407713c0dc0191d97b7cef3991fa8f3c53f0ee56d2" + }, + { + "name": "opentelemetry-semantic-conventions", + "version": "0.65b0", + "files": 134, + "bytes": 743112, + "sha256": "42002e8e7c0776c7d07c0440df12ac63f2eb208dac7fa85f244d9a07d218bcf2" + }, + { + "name": "orjson", + "version": "3.11.9", + "files": 13, + "bytes": 352688, + "sha256": "d668e683a6320d1d73d9635b57f3fafea18dde38252a41a10cc9fb892193023d" + }, + { + "name": "patchright", + "version": "1.60.1", + "files": 183, + "bytes": 140818208, + "sha256": "445cdc52db586bc4ddec7a835cd08a1cd8b55a2d80735099442976d4a08f291a" + }, + { + "name": "pillow", + "version": "12.3.0", + "files": 142, + "bytes": 19667544, + "sha256": "247ad09cc26652e1f1cf4f980ef1339e79f3e7504b196a751558b40df63b6bbf" + }, + { + "name": "playwright", + "version": "1.60.0", + "files": 185, + "bytes": 140805179, + "sha256": "8404349f219f80826bb80bc1d68b374656625652dabca158a1668269db7696fb" + }, + { + "name": "protego", + "version": "0.6.2", + "files": 12, + "bytes": 28596, + "sha256": "cf39fd8eab7017560c5fe81b59e89f7ceb92248bf074f988986e8d23fd51b1d0" + }, + { + "name": "pycparser", + "version": "3.0", + "files": 15, + "bytes": 203921, + "sha256": "ad9400178a2fe12234056d27bf985d37378d014e085c924b7715406af46fdded" + }, + { + "name": "pydantic", + "version": "2.13.4", + "files": 113, + "bytes": 1892871, + "sha256": "8648d40095c62fe62bae5ac9b15394da525f14ccac178feef6ef82a620353a22" + }, + { + "name": "pydantic-core", + "version": "2.46.4", + "files": 12, + "bytes": 5093450, + "sha256": "7c3fe076792371e916bfcf70efb9edba0a7179409861cfbee35a20531dbb4685" + }, + { + "name": "pyee", + "version": "13.0.1", + "files": 16, + "bytes": 39927, + "sha256": "a6093ed090fc610834f8f397bdd5a4feb4047c1572971a809f6f4673e5f6ded2" + }, + { + "name": "scrapling", + "version": "0.4.9", + "files": 61, + "bytes": 621058, + "sha256": "e935f73a0e5706a77551c4c51b8b89eaaf9ff634a5a9f0eb064d793516594637" + }, + { + "name": "six", + "version": "1.17.0", + "files": 8, + "bytes": 38145, + "sha256": "bf8933e0bd4a4b45d3b337fc4771210ab6d860681d86c8aef67a5a50fa8021a1" + }, + { + "name": "soupsieve", + "version": "2.9.2", + "files": 14, + "bytes": 144415, + "sha256": "e55c73152b6666d758977d1c670f1658ba51c1995a2cea4c07cc2d04d94b6cac" + }, + { + "name": "tld", + "version": "0.13.2", + "files": 31, + "bytes": 1024190, + "sha256": "36e01b4a41726ce48b579a539ed86fb0d5f48cca5cd54c92c49e0dc48985fced" + }, + { + "name": "typing-extensions", + "version": "4.16.0", + "files": 7, + "bytes": 182965, + "sha256": "64d4dcd2b424c528797ea620f1e0aded10391e95125ba31ce56643ea27a817ed" + }, + { + "name": "typing-inspection", + "version": "0.4.4", + "files": 11, + "bytes": 54734, + "sha256": "1e86a8a56714eb2853aa47d06ababb7b51ac51df8761c579c80c0233992b8f10" + }, + { + "name": "w3lib", + "version": "2.4.1", + "files": 16, + "bytes": 57644, + "sha256": "d7214a026ae9d960d973b775bab3f699e2241a943b170fa6b4d76adc40c1241e" + }, + { + "name": "websockets", + "version": "17.0.1", + "files": 65, + "bytes": 809446, + "sha256": "5f73bd281414c46c111902c87b1018e67c332fa49e5784be94c8aa4031a0da62" + } + ], + "requirements_lock": { + "path": "oracle/requirements.lock", + "size": 74627, + "sha256": "7f787cee0522e4b2cd78af36983be79cb43459ba797e5ff7f565925ffec67a7d" + } + }, + "browser": { + "playwright_revision": "1223", + "chromium_version": "148.0.7778.96", + "archives": [ + { + "name": "chromium-linux-x64", + "url": "https://cdn.playwright.dev/builds/cft/148.0.7778.96/linux64/chrome-linux64.zip", + "path": "pw-chromium-1223-linux-x64.zip", + "size": 183945705, + "sha256": "e58a0612f6156ad287ff392c681f6e1154d0c938feccf631d0c5d7abf3bbf81a" + }, + { + "name": "chromium-headless-shell-linux-x64", + "url": "https://cdn.playwright.dev/builds/cft/148.0.7778.96/linux64/chrome-headless-shell-linux64.zip", + "path": "pw-headless-1223-linux-x64.zip", + "size": 118673688, + "sha256": "88817c574c1838a39f88fda0bbd043b4481fd385fb10e92c323e230844d636ce" + }, + { + "name": "ffmpeg-linux-x64", + "url": "https://cdn.playwright.dev/dbazure/download/playwright/builds/ffmpeg/1011/ffmpeg-linux.zip", + "path": "pw-ffmpeg-1011-linux-x64.zip", + "size": 2376500, + "sha256": "ebc74fc5b94830176a3c2914ae96bd8bc7f6a91f4f33890230f84a172ee61ccc" + }, + { + "name": "chromium-linux-arm64", + "url": "https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1223/chromium-linux-arm64.zip", + "path": "pw-chromium-1223-linux-arm64.zip", + "size": 194706293, + "sha256": "3f7fd102f646dad864a987de04782e238e98a3d38543a1c8be0129b96706a283" + }, + { + "name": "chromium-headless-shell-linux-arm64", + "url": "https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1223/chromium-headless-shell-linux-arm64.zip", + "path": "pw-headless-1223-linux-arm64.zip", + "size": 114718332, + "sha256": "4578d731ef7f2f344ae1d79c0ae2664453fc0848ccaa726856a5e9d34aaa1a0e" + }, + { + "name": "ffmpeg-linux-arm64", + "url": "https://cdn.playwright.dev/dbazure/download/playwright/builds/ffmpeg/1011/ffmpeg-linux-arm64.zip", + "path": "pw-ffmpeg-1011-linux-arm64.zip", + "size": 1717234, + "sha256": "2628c03f05318ff812c8c9baaf207dea2ddf53e818c0dc936714b0fbe3afb009" + } + ] + }, + "assets": [ + { + "package": "anyio", + "path": "anyio-4.14.2.dist-info/entry_points.txt", + "size": 39, + "sha256": "fdde98bbaba269998d7b40b2768c22ac4f429a0ef350bda0d3cb50a684b742f7" + }, + { + "package": "anyio", + "path": "anyio-4.14.2.dist-info/scm_file_list.json", + "size": 3654, + "sha256": "c034971aff04867e595b906107e465680735eb363f39f3b6eeaae531f30c672f" + }, + { + "package": "anyio", + "path": "anyio-4.14.2.dist-info/scm_version.json", + "size": 161, + "sha256": "2a0694c77d52c9aa8615014aa46dc53bd90293eca038a4bb51fd32939e8ecda6" + }, + { + "package": "anyio", + "path": "anyio-4.14.2.dist-info/top_level.txt", + "size": 6, + "sha256": "420952322597f3fe5da685401081dd118cefa8531d4985a60a3ead630d884e44" + }, + { + "package": "apify-fingerprint-datapoints", + "path": "apify_fingerprint_datapoints/data/browser-helper-file.json", + "size": 2672, + "sha256": "965ee8894d3a4e7815d02e650be9fecf3c3c7e91323b537e2e77371295313aac" + }, + { + "package": "apify-fingerprint-datapoints", + "path": "apify_fingerprint_datapoints/data/fingerprint-network-definition.zip", + "size": 705185, + "sha256": "5cc4668d858e0365a7ab0feeaeed8ce63b37747d06924070403e9eaa88057d4e" + }, + { + "package": "apify-fingerprint-datapoints", + "path": "apify_fingerprint_datapoints/data/header-network-definition.zip", + "size": 43573, + "sha256": "67a17948164967e0c23ec1df652b91d746ae05d42a6fc22b2d93e521e7f3fe0a" + }, + { + "package": "apify-fingerprint-datapoints", + "path": "apify_fingerprint_datapoints/data/headers-order.json", + "size": 3691, + "sha256": "3e89bfcf8f648bd5df76aa45d80e075c65341a0402b64b9d79c96dbc00fb4db6" + }, + { + "package": "apify-fingerprint-datapoints", + "path": "apify_fingerprint_datapoints/data/input-network-definition.zip", + "size": 4550, + "sha256": "3b918d231c70202c5b0755c6e9f0e8a250e7a6dc21260ecd7cb936028585c19d" + }, + { + "package": "browserforge", + "path": "browserforge/injectors/data/utils.js.xz", + "size": 6512, + "sha256": "07877eae2e88f390125d31d21edf234fc6a4ed1d333fcc630924a6134772ec0a" + }, + { + "package": "certifi", + "path": "certifi-2026.7.22.dist-info/top_level.txt", + "size": 8, + "sha256": "28cbb8bd409fb232eb90f6d235d81d7a44bea552730402453bffe723c345ebe5" + }, + { + "package": "certifi", + "path": "certifi/cacert.pem", + "size": 240216, + "sha256": "9cc2a774b5198dcff14d9be1e66091f538975d867ce029a96bce15a55dfd730f" + }, + { + "package": "cffi", + "path": "cffi-2.1.1.dist-info/entry_points.txt", + "size": 132, + "sha256": "a6b6108011f8e27050fe5269bba88c8051b414697880d3b0b6fbe3a03a0b001c" + }, + { + "package": "cffi", + "path": "cffi-2.1.1.dist-info/top_level.txt", + "size": 19, + "sha256": "ac4ed6477ad97cd2b1588f7e8e7ea1b0708097b303901f859ae41bc568c57a14" + }, + { + "package": "click", + "path": "click-8.4.2.dist-info/licenses/LICENSE.txt", + "size": 1475, + "sha256": "9a8ad106a394e853bfe21f42f4e72d592819a22805d991b5f3275029292b658d" + }, + { + "package": "curl-cffi", + "path": "curl_cffi-0.16.0.dist-info/entry_points.txt", + "size": 49, + "sha256": "1f34f0a14a5b88a228e6e26baa7c4f93939dc59d0f853d1d3d34aa50ff09b3bd" + }, + { + "package": "curl-cffi", + "path": "curl_cffi-0.16.0.dist-info/top_level.txt", + "size": 10, + "sha256": "6f9d58079d08fefbba5c06d21119aab606987226000c203f6da56867f4f92f3b" + }, + { + "package": "greenlet", + "path": "greenlet-3.5.5.dist-info/top_level.txt", + "size": 9, + "sha256": "6129d1b024683bad491a53f9ee8f2228beab74b5835ae8b2283f1e929594b037" + }, + { + "package": "h11", + "path": "h11-0.16.0.dist-info/licenses/LICENSE.txt", + "size": 1124, + "sha256": "37db5bb85926db28a427a25867f10b1232003aea1be69ccb851138adb8e6f361" + }, + { + "package": "h11", + "path": "h11-0.16.0.dist-info/top_level.txt", + "size": 4, + "sha256": "17b742e23977cde87c4c61c43da589acc6deba859b4b7efd1b0762f98bdd722b" + }, + { + "package": "httpx", + "path": "httpx-0.28.1.dist-info/entry_points.txt", + "size": 37, + "sha256": "da55647509b12c0d6934c812376795f3da3dd070997b3866370b32eabc8a0d20" + }, + { + "package": "idna", + "path": "idna-3.18.dist-info/entry_points.txt", + "size": 38, + "sha256": "ec7de718e1daa778e72c4e5eeeaed8c2bf55abc6b107b5888f9f82ff8376be1c" + }, + { + "package": "lxml", + "path": "lxml-6.1.1.dist-info/licenses/LICENSE.txt", + "size": 1507, + "sha256": "8fc2b568133516e46845d2147917adeee1648e70ae9ab5ed6c5417afef4ce855" + }, + { + "package": "lxml", + "path": "lxml-6.1.1.dist-info/licenses/LICENSES.txt", + "size": 1514, + "sha256": "41d49dd406aa0e1548a6d5f21a30d6bf638b3cd96eb7289dd348d83ed2e40392" + }, + { + "package": "lxml", + "path": "lxml-6.1.1.dist-info/top_level.txt", + "size": 5, + "sha256": "3630fdf3cc2a68aab9d769ec84d74bb7eb83c6c8e4a78061e759ba37e76152b9" + }, + { + "package": "lxml", + "path": "lxml/isoschematron/resources/xsl/iso-schematron-xslt1/readme.txt", + "size": 3310, + "sha256": "3862e216cc2e2c9116e443d828e7a86aeb8224512d55aea3cb3044d4e70923df" + }, + { + "package": "markdownify", + "path": "markdownify-1.2.3.dist-info/entry_points.txt", + "size": 54, + "sha256": "6b0eb3d1e3fe24d80352dfe6a1fb720bc87612ae99a335e275fb73e2f3cb2fa8" + }, + { + "package": "markdownify", + "path": "markdownify-1.2.3.dist-info/top_level.txt", + "size": 12, + "sha256": "85ae3d28ba10e1f3ede620d39c0f02001e431e20122c1e83f1bb8fab3ccbaa0a" + }, + { + "package": "msgspec", + "path": "msgspec-0.21.1.dist-info/top_level.txt", + "size": 8, + "sha256": "f18b27c5adbd40b85d3ed8cf1a3c9cd619b1de73a629ebbf380936ebcd9976f5" + }, + { + "package": "opentelemetry-api", + "path": "opentelemetry_api-1.44.0.dist-info/entry_points.txt", + "size": 573, + "sha256": "7713ead1845b4034b097c42447e23d037f2b6db7ca406e61dae3458e9bd4e95e" + }, + { + "package": "opentelemetry-sdk", + "path": "opentelemetry_sdk-1.44.0.dist-info/entry_points.txt", + "size": 1544, + "sha256": "ebf9392532b2741c3fcc346f9051db62cf5dfe566b5cd7fad110375a0faff3de" + }, + { + "package": "orjson", + "path": "orjson-3.11.9.dist-info/sboms/orjson.cyclonedx.json", + "size": 33096, + "sha256": "718e3956a637a6fb385994b25f243d048f4d5ea1d3d2b713cea4f1a481bf7050" + }, + { + "package": "patchright", + "path": "patchright-1.60.1.dist-info/entry_points.txt", + "size": 130, + "sha256": "7f58020629a8db11d98c420e154e64fb525a2afc3e19accf1d41d7a319d66a8f" + }, + { + "package": "patchright", + "path": "patchright-1.60.1.dist-info/top_level.txt", + "size": 11, + "sha256": "6d3b9fca3b2887575bc51d585b2d1665ef238ef550c632170f0a32f6dc8aaca7" + }, + { + "package": "patchright", + "path": "patchright/driver/package/ThirdPartyNotices.txt", + "size": 676, + "sha256": "a549d329bad8806fe279f0ecae0fc0270dec7e7c2dc8ec10f90e394f7c32b144" + }, + { + "package": "patchright", + "path": "patchright/driver/package/api.json", + "size": 2824926, + "sha256": "797034dbb082067a885449d818437d9d05af26f493785da6cb0be1cd74c26142" + }, + { + "package": "patchright", + "path": "patchright/driver/package/browsers.json", + "size": 1941, + "sha256": "af53e32ffe35a024ddb34563700956b01ada00ac7e9270ba5df0604ec57e38e1" + }, + { + "package": "patchright", + "path": "patchright/driver/package/lib/server/deviceDescriptorsSource.json", + "size": 54181, + "sha256": "933d568bfa2d50de5805ce52f55a33509c66c8b8b7aa2c07a82d5475c39fe851" + }, + { + "package": "patchright", + "path": "patchright/driver/package/lib/tools/cli-client/help.json", + "size": 35416, + "sha256": "baf768247fc0cfaa6d1122c6437d957ddfa9cb05ce30effaeb9f4e8d68383b69" + }, + { + "package": "patchright", + "path": "patchright/driver/package/package.json", + "size": 876, + "sha256": "6f7b58cc55449321279f11ca97d4e451c391738b77db32cdbcedf02851e3f097" + }, + { + "package": "pillow", + "path": "pillow-12.3.0.dist-info/sboms/auditwheel.cdx.json", + "size": 1358, + "sha256": "303b21ef5fdafd808394a3e28a4b17db8ebc92f47bf6be28a3e9ec02cd3173d5" + }, + { + "package": "pillow", + "path": "pillow-12.3.0.dist-info/sboms/pillow-12.3.0.cdx.json", + "size": 22279, + "sha256": "40fa909b0f23405a7b97a6d91671a639a40acea7671b04387da22ba3e9fca50a" + }, + { + "package": "pillow", + "path": "pillow-12.3.0.dist-info/top_level.txt", + "size": 4, + "sha256": "ae266aae4fa1c99aa1e5fd59d19c228b774a7f112c07286ef5c53d20e0c5f8d6" + }, + { + "package": "playwright", + "path": "playwright-1.60.0.dist-info/entry_points.txt", + "size": 130, + "sha256": "1be952e6d9d0c36d4f1dc5ca6d9146cbcd657338a775720351e8c879f1fc5686" + }, + { + "package": "playwright", + "path": "playwright-1.60.0.dist-info/top_level.txt", + "size": 11, + "sha256": "a188b3e7e65be32db700d668b768b794cb0af81022c17a93950e21cad2c962e1" + }, + { + "package": "playwright", + "path": "playwright/driver/package/ThirdPartyNotices.txt", + "size": 676, + "sha256": "a549d329bad8806fe279f0ecae0fc0270dec7e7c2dc8ec10f90e394f7c32b144" + }, + { + "package": "playwright", + "path": "playwright/driver/package/api.json", + "size": 2824926, + "sha256": "797034dbb082067a885449d818437d9d05af26f493785da6cb0be1cd74c26142" + }, + { + "package": "playwright", + "path": "playwright/driver/package/browsers.json", + "size": 1941, + "sha256": "af53e32ffe35a024ddb34563700956b01ada00ac7e9270ba5df0604ec57e38e1" + }, + { + "package": "playwright", + "path": "playwright/driver/package/lib/server/deviceDescriptorsSource.json", + "size": 54181, + "sha256": "933d568bfa2d50de5805ce52f55a33509c66c8b8b7aa2c07a82d5475c39fe851" + }, + { + "package": "playwright", + "path": "playwright/driver/package/lib/tools/cli-client/help.json", + "size": 35416, + "sha256": "baf768247fc0cfaa6d1122c6437d957ddfa9cb05ce30effaeb9f4e8d68383b69" + }, + { + "package": "playwright", + "path": "playwright/driver/package/package.json", + "size": 876, + "sha256": "6f7b58cc55449321279f11ca97d4e451c391738b77db32cdbcedf02851e3f097" + }, + { + "package": "pycparser", + "path": "pycparser-3.0.dist-info/top_level.txt", + "size": 10, + "sha256": "73e94f712ef82fff0aa07ec813a3d0179a1fca2ad140d57856191b48520f7963" + }, + { + "package": "pydantic-core", + "path": "pydantic_core-2.46.4.dist-info/sboms/pydantic-core.cyclonedx.json", + "size": 125376, + "sha256": "34670207347e90bf2c010cd61dd75f6b340d146b6523c057c3d5bae3efac5fce" + }, + { + "package": "pyee", + "path": "pyee-13.0.1.dist-info/top_level.txt", + "size": 5, + "sha256": "868bf9b972abc68a66ac58878534a0980ce2213f179c6935ab67dd71d4af4448" + }, + { + "package": "scrapling", + "path": "scrapling-0.4.9.dist-info/entry_points.txt", + "size": 49, + "sha256": "0c7cadd81971cb43f9384d8745c3fde56cfdff1a3611108370daab263612de8f" + }, + { + "package": "scrapling", + "path": "scrapling-0.4.9.dist-info/top_level.txt", + "size": 10, + "sha256": "51dfb217e3c2d94e474379dce50c13ec748f748a45d51bb043f998801cc71c83" + }, + { + "package": "six", + "path": "six-1.17.0.dist-info/top_level.txt", + "size": 4, + "sha256": "fe2547fe2604b445e70fc9d819062960552f9145bdb043b51986e478a4806a2b" + }, + { + "package": "tld", + "path": "tld-0.13.2.dist-info/entry_points.txt", + "size": 68, + "sha256": "f4bb7911529062323fac250bb6e9ddea89af3813f6186b7e78ea92717fb2c8f9" + }, + { + "package": "tld", + "path": "tld-0.13.2.dist-info/licenses/LICENSE_GPL2.0.txt", + "size": 18083, + "sha256": "df0f0b30b87a86746921a95164387878646d6aa0683a7b211fa8a40e114d6111" + }, + { + "package": "tld", + "path": "tld-0.13.2.dist-info/licenses/LICENSE_LGPL_2.1.txt", + "size": 26434, + "sha256": "96131738ed6ddc1cde3e1482a125a227b5a5b4988131b0b4bc275cfe373664d8" + }, + { + "package": "tld", + "path": "tld-0.13.2.dist-info/licenses/LICENSE_MPL_1.1.txt", + "size": 25679, + "sha256": "3ea07a3dffe4f0b5ef7a92a3f474198dfdf7daca596c8bdbbcf184ae20443a8c" + }, + { + "package": "tld", + "path": "tld-0.13.2.dist-info/top_level.txt", + "size": 4, + "sha256": "7bacc7a8f1178265c9760e7b4dab89b3cda491a6e12287f03bf2769fbfa89437" + }, + { + "package": "tld", + "path": "tld/res/effective_tld_names.dat.txt", + "size": 330904, + "sha256": "abf32ce9987d505b89765d76f35760543851235508f1f426b5b259a2062b5f68" + }, + { + "package": "tld", + "path": "tld/res/effective_tld_names_public_only.dat.txt", + "size": 330904, + "sha256": "abf32ce9987d505b89765d76f35760543851235508f1f426b5b259a2062b5f68" + }, + { + "package": "tld", + "path": "tld/tests/res/effective_tld_names_custom.dat.txt", + "size": 216078, + "sha256": "6c9f46f63211d221519590b22fb0f5687923c9ce1212394c51df9ef4a337006e" + }, + { + "package": "websockets", + "path": "websockets-17.0.1.dist-info/entry_points.txt", + "size": 51, + "sha256": "0e7867e1d9b912c23864c02c1e574617a0b005766b1979d1edc9cadbe5d1ef36" + }, + { + "package": "websockets", + "path": "websockets-17.0.1.dist-info/top_level.txt", + "size": 11, + "sha256": "08ca5d2a49712acbd980283296dc5458e1e26d95d9d6e60856971af71b10f079" + } + ], + "host": { + "locale": "C.UTF-8", + "locale_environment": { + "LANG": "en_US.UTF-8", + "LC_ALL": "C.UTF-8", + "LC_CTYPE": "C.UTF-8" + }, + "timezone": { + "name": "America/Recife", + "path": "/usr/share/zoneinfo/America/Recife", + "size": 702, + "sha256": "6c9fc7134f89162a38fa8c29674a4b3bc5376a2d1f886bbc4072f40dec4b88b7" + }, + "ca_bundle": { + "path": "certifi/cacert.pem", + "size": 240216, + "sha256": "9cc2a774b5198dcff14d9be1e66091f538975d867ce029a96bce15a55dfd730f" + }, + "fonts_sha256": "6ddb3237d7e1f8de3cee30f7eda3984abe80634e0dfa6d2c1a4d7e5101319e72", + "fonts": [ + { + "path": "/usr/share/fonts/Adwaita/AdwaitaMono-Bold.ttf", + "size": 1434960, + "sha256": "6378ff11be9b3da218efadefc4e459997d8e425725303ac5112f33c465057ae6" + }, + { + "path": "/usr/share/fonts/Adwaita/AdwaitaMono-BoldItalic.ttf", + "size": 1494108, + "sha256": "19d284011cd186c178a982cfd3c6a365d94a6bf5d35879146dd630a69abee5a3" + }, + { + "path": "/usr/share/fonts/Adwaita/AdwaitaMono-Italic.ttf", + "size": 1487896, + "sha256": "3207d793512e9ccb5832c1f8efcf22ab906d9e74f4920125700683df306d5cc8" + }, + { + "path": "/usr/share/fonts/Adwaita/AdwaitaMono-Regular.ttf", + "size": 1435180, + "sha256": "0edc6a8d8ca249f594dee661b2f57f1a3baa33bc7aca826c6a9fe27b06f9f930" + }, + { + "path": "/usr/share/fonts/Adwaita/AdwaitaSans-Italic.ttf", + "size": 910352, + "sha256": "a3dc55af75f746756596dca37f3ff7c0f0b015f2fd2e87f2cf0ef4a69d4f3015" + }, + { + "path": "/usr/share/fonts/Adwaita/AdwaitaSans-Regular.ttf", + "size": 879796, + "sha256": "8381c33b9a44f066f2b99dba3d416a2342891e28c956a35dfd8d16ee2987e6d4" + }, + { + "path": "/usr/share/fonts/TTF/AkaashNormal.ttf", + "size": 147764, + "sha256": "5068e22d5dda56762e2e064025a5d89b50c6cedb000ff74e1efd4be52194a53e" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuMathTeXGyre.ttf", + "size": 577192, + "sha256": "402d84765572444ae638e367a1853b28e10bb418a7a840b02ab11993ed1423c5" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", + "size": 708920, + "sha256": "b5d64817b6331723b5e59eaaa6db90057cbed58e9733f65687f110638192359f" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSans-BoldOblique.ttf", + "size": 645600, + "sha256": "ec93b91f398f68f5522d227afd5ed8e84b9cb0c6ad56c1172dcb1035a1542a80" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSans-ExtraLight.ttf", + "size": 355820, + "sha256": "35cdde3e903f78239ed3455881623f73aef75e4830a179c45896f9166633e48a" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSans-Oblique.ttf", + "size": 637648, + "sha256": "cf41fd01245e40e783ce3eae0bba5c38927a2497889dde1266d8757b9c84de1a" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSans.ttf", + "size": 759720, + "sha256": "6038a160b491e121c1f12c7bccb4a9c8730296e3adc1086a059404ed84b7451c" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSansCondensed-Bold.ttf", + "size": 667844, + "sha256": "586556501565e46ad356a5efcc2f6e81375230323ad5a2a1c4cc8211a6c5ef2e" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSansCondensed-BoldOblique.ttf", + "size": 614068, + "sha256": "fa3aa81bb95a5458528c84968818aba025d29b677715aeb822cec84ed5f77248" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSansCondensed-Oblique.ttf", + "size": 601448, + "sha256": "e0c6853fed35b40085a907728d45cf473c6854cb7cfc411147dfe0cb6b3f3145" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSansCondensed.ttf", + "size": 682828, + "sha256": "309fbf4d74e8ed6884269716677d45811dcb2d8edea13d7df31efda4807d2df5" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSansMono-Bold.ttf", + "size": 334268, + "sha256": "738db66c1f30008ddc331e46ba373f0b28c9455f54f0174e4aee670d6999193a" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSansMono-BoldOblique.ttf", + "size": 254960, + "sha256": "4a6ecb8975cbdc3ec1bf0963a5b285b724937457302539536e848564bec37e79" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSansMono-Oblique.ttf", + "size": 253448, + "sha256": "94d1c1b9f930b3d7c81780cd4e70a2f210bc6ba1bc1c4de00774d6ce42944079" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSansMono.ttf", + "size": 343140, + "sha256": "b5babc084554ebdd142fe099d8b7c573ff161738bca913723d3e84f5c3df1c78" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSerif-Bold.ttf", + "size": 356668, + "sha256": "8061b471398f4903262c0050d2c9eeb96a78bdbaee759710fe1cec9386bfbe03" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSerif-BoldItalic.ttf", + "size": 348020, + "sha256": "22c692e9a7d71773a790117994a859961e469b35610b07d9d750c4145d01b34e" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSerif-Italic.ttf", + "size": 346544, + "sha256": "97886f871f0d98bfe6d4eaaab75980f2f6b2ef28d541b50c6216f449001eead2" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSerif.ttf", + "size": 380660, + "sha256": "ae665174c5b7fdb9b457d112db098cbe651dff847bfcf8bcb711172856d0c6b1" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSerifCondensed-Bold.ttf", + "size": 331820, + "sha256": "04444becce74a39ef948a0ee452bda9771d8baa5037194add9d0d1e430aa5338" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSerifCondensed-BoldItalic.ttf", + "size": 347076, + "sha256": "b191860977ce0b9d16e409835a7b0060890840935e74107f0446c43e3784e7de" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSerifCondensed-Italic.ttf", + "size": 345868, + "sha256": "1b3a8e0672558865339b5ebf63489ae139ddc12d60187df5304930087b1c7185" + }, + { + "path": "/usr/share/fonts/TTF/DejaVuSerifCondensed.ttf", + "size": 347208, + "sha256": "c2dcf4ca7ee1d7596fd651006aaf51f5926158a53ce3c862a2d06ad824273e62" + }, + { + "path": "/usr/share/fonts/TTF/Gargi-1.2b.ttf", + "size": 117744, + "sha256": "a9f177f9ecf62e24e95b187ba731594241690ef508ca4c7773bc56e03717ac0f" + }, + { + "path": "/usr/share/fonts/TTF/GurbaniBoliLite.ttf", + "size": 14372, + "sha256": "66c22766a7b392820ab39ca7b33457c29de7406d79c612a4016d664aac6192c3" + }, + { + "path": "/usr/share/fonts/TTF/HackNerdFont-Bold.ttf", + "size": 2762164, + "sha256": "b15a33b0328a17737bc736c52e7508f0f1a7c328b51cc7418fdab9a2c8f97aff" + }, + { + "path": "/usr/share/fonts/TTF/HackNerdFont-BoldItalic.ttf", + "size": 2767840, + "sha256": "b502d33b1fda2253e0d908e9d08ef65edd926429fdfee317df8a57bb350af3ec" + }, + { + "path": "/usr/share/fonts/TTF/HackNerdFont-Italic.ttf", + "size": 2761208, + "sha256": "9daf976dd2a05cd43c19c2cb5dc1f01e8c5f79b6da86049fa06ea62fbb0a0616" + }, + { + "path": "/usr/share/fonts/TTF/HackNerdFont-Regular.ttf", + "size": 2753768, + "sha256": "0b4da1253399c686cd1c060021d710dc4eebaa4a4e9d76cac9c62a287c9569ce" + }, + { + "path": "/usr/share/fonts/TTF/HackNerdFontMono-Bold.ttf", + "size": 2740828, + "sha256": "e5cfb2d7df9a84fc5371320d059a4cc8d3470ffe7c1295efbff7541162730c34" + }, + { + "path": "/usr/share/fonts/TTF/HackNerdFontMono-BoldItalic.ttf", + "size": 2746596, + "sha256": "4b0ebfb7f64c85cb00875638901e496392049d600181891ab84a87ce20818df6" + }, + { + "path": "/usr/share/fonts/TTF/HackNerdFontMono-Italic.ttf", + "size": 2739972, + "sha256": "25d5388eff9b6b17598917ac772549a2827da4f8ca408843f72c39ba64272fbc" + }, + { + "path": "/usr/share/fonts/TTF/HackNerdFontMono-Regular.ttf", + "size": 2732596, + "sha256": "28a157c93f850c603faf77819654925fdaf3abc431aefcb6f89ecb08d22f0a3e" + }, + { + "path": "/usr/share/fonts/TTF/HackNerdFontPropo-Bold.ttf", + "size": 2761956, + "sha256": "51361a3ad1422abb551faebce2941c722e0241d7eb24b4868860297a1c2738c4" + }, + { + "path": "/usr/share/fonts/TTF/HackNerdFontPropo-BoldItalic.ttf", + "size": 2767632, + "sha256": "780b4755a3f3f3189a4161043b831a6aa21b8c5505ff03f0af7a7c023b68cf61" + }, + { + "path": "/usr/share/fonts/TTF/HackNerdFontPropo-Italic.ttf", + "size": 2761000, + "sha256": "22fb8a29ca9bc8179522c8534df4faae40ab0537de0118f4a1c65b2d3a8ef5c1" + }, + { + "path": "/usr/share/fonts/TTF/HackNerdFontPropo-Regular.ttf", + "size": 2753564, + "sha256": "d865cb6a46557de06c072efed0cbd519a6651c028d52189e79146ae4dd2938f8" + }, + { + "path": "/usr/share/fonts/TTF/LikhanNormal.ttf", + "size": 92808, + "sha256": "b6f58b32907c57e0d6f01a6624c4ae9b8226c05d059dbe7a3e5d603e0a66500c" + }, + { + "path": "/usr/share/fonts/TTF/MalOtf.ttf", + "size": 90244, + "sha256": "2fea9a759d82610a7cea85d6bf6eef46a586a32231f10819421e67b642501905" + }, + { + "path": "/usr/share/fonts/TTF/MesloLGS-NF-Bold-Italic.ttf", + "size": 2561984, + "sha256": "56b4131adecec052c4b324efb818dd326d586dbc316fc68f98f1cae2eb8d1220" + }, + { + "path": "/usr/share/fonts/TTF/MesloLGS-NF-Bold.ttf", + "size": 2603868, + "sha256": "b6c0199cf7c7483c8343ea020658925e6de0aeb318b89908152fcb4d19226003" + }, + { + "path": "/usr/share/fonts/TTF/MesloLGS-NF-Italic.ttf", + "size": 2553260, + "sha256": "6f357bcbe2597704e157a915625928bca38364a89c22a4ac36e7a116dcd392ef" + }, + { + "path": "/usr/share/fonts/TTF/MesloLGS-NF-Regular.ttf", + "size": 2594368, + "sha256": "d97946186e97f8d7c0139e8983abf40a1d2d086924f2c5dbf1c29bd8f2c6e57d" + }, + { + "path": "/usr/share/fonts/TTF/MuktiNarrow.ttf", + "size": 185512, + "sha256": "3a1201b75b290530d12239561f916d3a826e58f1232a5a49604a53dafc9bc716" + }, + { + "path": "/usr/share/fonts/TTF/MuktiNarrowBold.ttf", + "size": 73936, + "sha256": "d71725f2e1f4b110f910dba7e66edd4f18d1e9d98f0a4d52f8b4c73368449116" + }, + { + "path": "/usr/share/fonts/TTF/Pothana2000.ttf", + "size": 180360, + "sha256": "81789d600d0f428281bc3c1d568fece32f88b97b12feb8c6504cf40cf0a21467" + }, + { + "path": "/usr/share/fonts/TTF/SagarNormal.ttf", + "size": 141996, + "sha256": "9bf84df5e59dd50e2324d9c270724c2bd7c2c40cea075023bec7732abea3e268" + }, + { + "path": "/usr/share/fonts/TTF/Sampige.ttf", + "size": 87608, + "sha256": "c53873750ed4b56cf6b290f94bf05d45cc4f24f771c2d2c0731e59a0b3ca493b" + }, + { + "path": "/usr/share/fonts/TTF/TAMu_Kadampari.ttf", + "size": 100136, + "sha256": "a1857dc77e3c682a636cdf921517ba0a6aa53b1594147a7da0190c34020cf78c" + }, + { + "path": "/usr/share/fonts/TTF/TAMu_Kalyani.ttf", + "size": 102320, + "sha256": "7ff683c172f7b3ea9e0c1ac806c5e9c5788cef024d209fd6c98bbc87214f55dd" + }, + { + "path": "/usr/share/fonts/TTF/TAMu_Maduram.ttf", + "size": 88588, + "sha256": "09492cd008f0dbb0fd5c74483dcfe9fafb92fe14351bf09ac7115109eb698bb7" + }, + { + "path": "/usr/share/fonts/TTF/TSCu_Comic.ttf", + "size": 75704, + "sha256": "04a9e461c611eae8777216cae3d7b5db0242cebcab270dee48efcad2e6299a83" + }, + { + "path": "/usr/share/fonts/TTF/TSCu_Paranar.ttf", + "size": 63048, + "sha256": "0c30fde7d511e4a8f8c351262b7d27627e7ef9d7c8d601c38b088713596d5141" + }, + { + "path": "/usr/share/fonts/TTF/TSCu_Times.ttf", + "size": 61164, + "sha256": "edbcfd9ee66035b2508ab27e982ffc3b32c9bcfc4db76b6e6b111d9bfe17bd95" + }, + { + "path": "/usr/share/fonts/TTF/TSCu_paranarb.ttf", + "size": 84968, + "sha256": "438fcef6e59e19a69329d6577310fe184bb0ea4147f17b9edc8fa2c8f2bf32f0" + }, + { + "path": "/usr/share/fonts/TTF/TSCu_paranari.ttf", + "size": 71140, + "sha256": "77b4f540783d86789b90fa50b46d3a32ae19b1701c5aacd870395bed68e89a01" + }, + { + "path": "/usr/share/fonts/TTF/akruti1.ttf", + "size": 72760, + "sha256": "209582dbf4011807983fca07c9de9ccb9ffc141930658f4e43130de3d673a4d6" + }, + { + "path": "/usr/share/fonts/TTF/akruti1b.ttf", + "size": 73388, + "sha256": "4ca68c09cfcb4fb3b94fb86eab271c1d2d89ee2525192cc4278f0d040fcdfbd4" + }, + { + "path": "/usr/share/fonts/TTF/akruti2.ttf", + "size": 73788, + "sha256": "472998b05bf1d931d80465d9b68f406ff7712fc11cd83b7447eae6accc9986d6" + }, + { + "path": "/usr/share/fonts/TTF/akruti2b.ttf", + "size": 74276, + "sha256": "83a2f8540ca1ebc2dffa3849ca20732cafb08189b2ac01cc1d4071a14c797577" + }, + { + "path": "/usr/share/fonts/TTF/ani.ttf", + "size": 96780, + "sha256": "60eeb07b5b3e55a08453800bd9b15930efdb676728738567aecfed3f1f54b2fc" + }, + { + "path": "/usr/share/fonts/TTF/gbolilite.ttf", + "size": 22492, + "sha256": "60ab92d6239c9d210467a43e0514463c6a8b5f1f4eaa5bd9c131f87f1b5c896a" + }, + { + "path": "/usr/share/fonts/TTF/mal1-b.ttf", + "size": 69068, + "sha256": "8aafd9bed477ebf5da159ac79296a5cb44c756675b19e7e18997ac3bc8a8e142" + }, + { + "path": "/usr/share/fonts/TTF/mal1-n.ttf", + "size": 69408, + "sha256": "a5edbf1fe5e81369260a59cf2c184d7284017c9e776b719e54e3e77c191f6cb4" + }, + { + "path": "/usr/share/fonts/TTF/mal2-b.ttf", + "size": 70900, + "sha256": "97df98725c8a46fd74b76abf662cecf930a367e19ea43eade2a9387ac84fb107" + }, + { + "path": "/usr/share/fonts/TTF/mal2-n.ttf", + "size": 66252, + "sha256": "00375ae443b355e8571d857b00eec4bbbfe79477523ebcc716d9e0d28bae0952" + }, + { + "path": "/usr/share/fonts/TTF/malayalam.ttf", + "size": 86568, + "sha256": "673e7da0b9a8998f6d6638bc201de13ed46497e06232c2bef72d723ade2bc754" + }, + { + "path": "/usr/share/fonts/TTF/oriya.ttf", + "size": 23164, + "sha256": "a2e00a3e50709a3631ccb648dded940e3f4ad57cabaf181893775035b4d6efcf" + }, + { + "path": "/usr/share/fonts/TTF/padmaa-Bold-0.5.ttf", + "size": 50860, + "sha256": "82d4ec160f9efb9440c76101a86de99008a448604f6524d8bac8b2e6efe05a31" + }, + { + "path": "/usr/share/fonts/TTF/padmaa-Medium-0.5.ttf", + "size": 52484, + "sha256": "81154fd0b661fe415e486352d3e8db0e11e0a3c6ee18ee7dec3c83cabd8ad626" + }, + { + "path": "/usr/share/fonts/gsfonts/C059-BdIta.otf", + "size": 103444, + "sha256": "60d0ab6a22e4018b1232806d98f4015e04f9a0a569db7a169c40d55e2a9f6252" + }, + { + "path": "/usr/share/fonts/gsfonts/C059-Bold.otf", + "size": 100692, + "sha256": "75dea10068264324522e7b96612083582eee0158eb728fa9c219279890f33e26" + }, + { + "path": "/usr/share/fonts/gsfonts/C059-Italic.otf", + "size": 101324, + "sha256": "77852deabd3a84f7d0213932239de522410309374c2255ccbd510f01dc7c6e80" + }, + { + "path": "/usr/share/fonts/gsfonts/C059-Roman.otf", + "size": 97476, + "sha256": "e00cc7b88f0cf25ae43f0e48de39d0043fe042b55719a3ac32669ab3369542ec" + }, + { + "path": "/usr/share/fonts/gsfonts/D050000L.otf", + "size": 29832, + "sha256": "a7bd946b69ae526328f26b5339fb31057dd40950d7b60598f36b2bd06542f105" + }, + { + "path": "/usr/share/fonts/gsfonts/NimbusMonoPS-Bold.otf", + "size": 87520, + "sha256": "f036d05d2168c7f71cb11d31e81d11133f3d09711e24ebde19d08a24842384d5" + }, + { + "path": "/usr/share/fonts/gsfonts/NimbusMonoPS-BoldItalic.otf", + "size": 89580, + "sha256": "a67ed9e364c933c79fc3ce88e17a0265334697c184b065407e754d43cbbf6d0a" + }, + { + "path": "/usr/share/fonts/gsfonts/NimbusMonoPS-Italic.otf", + "size": 82648, + "sha256": "7f1f85498027e07befadd3c7592518909b9e8c7f96ee5615e0b21be0c399c4b4" + }, + { + "path": "/usr/share/fonts/gsfonts/NimbusMonoPS-Regular.otf", + "size": 77936, + "sha256": "4f225ca8e13acb16f733ce741693105e527d5f7a5443901b9ecc190fca4e149b" + }, + { + "path": "/usr/share/fonts/gsfonts/NimbusRoman-Bold.otf", + "size": 100984, + "sha256": "e0ad81923bc6d85d5426f796217c87963ca1ae3c65e22c87a25b6c6353de299e" + }, + { + "path": "/usr/share/fonts/gsfonts/NimbusRoman-BoldItalic.otf", + "size": 104772, + "sha256": "c84bcb17ab4ee54f16dadad6a3b0b2df37a24f9fc812897c4a59eeca785a5dec" + }, + { + "path": "/usr/share/fonts/gsfonts/NimbusRoman-Italic.otf", + "size": 105684, + "sha256": "b7c424e6cb79baddfcdcd9a9d87f9fc4fdd06f0e5160066e2b2e47bcd647c7b9" + }, + { + "path": "/usr/share/fonts/gsfonts/NimbusRoman-Regular.otf", + "size": 98200, + "sha256": "fd41669443616a7ae890c42e53a58f47fa24a940fd1e367acaff665cf5b6e9d8" + }, + { + "path": "/usr/share/fonts/gsfonts/NimbusSans-Bold.otf", + "size": 83264, + "sha256": "7f33328e6b4d4cd21b45fa625791928c9407dc702db6780e56b09ca9a3ecaa67" + }, + { + "path": "/usr/share/fonts/gsfonts/NimbusSans-BoldItalic.otf", + "size": 95396, + "sha256": "3f47fb34fcb7de09f8cbc9f305191340ddebf7a068419f4bb5f49287dea59b87" + }, + { + "path": "/usr/share/fonts/gsfonts/NimbusSans-Italic.otf", + "size": 95244, + "sha256": "7b0bef5686aa58c0fd0f0d01beeae56664208490e30ca6a25431281c9a0c6402" + }, + { + "path": "/usr/share/fonts/gsfonts/NimbusSans-Regular.otf", + "size": 82264, + "sha256": "7c25be4d78155523080ab85b10277150657ff7dabbcad7037bdd536c9b6d0d08" + }, + { + "path": "/usr/share/fonts/gsfonts/NimbusSansNarrow-Bold.otf", + "size": 81340, + "sha256": "6d7cec07f5a9035e208532dab35a51312aef4a7f26eb524dd614c9fcd97c3264" + }, + { + "path": "/usr/share/fonts/gsfonts/NimbusSansNarrow-BoldOblique.otf", + "size": 87956, + "sha256": "3cefbe4a602a08a0400798fc4aba2d48f7b7e05fa6ed54c2009d68adc6eccef9" + }, + { + "path": "/usr/share/fonts/gsfonts/NimbusSansNarrow-Oblique.otf", + "size": 87068, + "sha256": "6fd8dd67ab951f36f66563ef8443bafdf10836e73ebe856f85f4a9c282293585" + }, + { + "path": "/usr/share/fonts/gsfonts/NimbusSansNarrow-Regular.otf", + "size": 80864, + "sha256": "0111cf7c05377c4f1685d37bfa8b3edd2033278122f69e0a9803a8514e352b99" + }, + { + "path": "/usr/share/fonts/gsfonts/P052-Bold.otf", + "size": 110980, + "sha256": "ba6503baaf0f9e4e40a69cc4c0e57049fbde313fe183aba8fe823208337a94af" + }, + { + "path": "/usr/share/fonts/gsfonts/P052-BoldItalic.otf", + "size": 110928, + "sha256": "63e15828175e55559ae09c9bdcd09fc0354537657d43109edb6e2a5509f8299f" + }, + { + "path": "/usr/share/fonts/gsfonts/P052-Italic.otf", + "size": 109824, + "sha256": "1feac6055211cf5c6c812c026e398195070f174064cc4d390f0db01647443b0e" + }, + { + "path": "/usr/share/fonts/gsfonts/P052-Roman.otf", + "size": 110236, + "sha256": "f054a7389d8d0e9f4c5f7b4c21feb7e2b14bec8004a1e92778a78d149524eea9" + }, + { + "path": "/usr/share/fonts/gsfonts/StandardSymbolsPS.otf", + "size": 21176, + "sha256": "df570efda2df425dbfc004e4e5f77c55ca1b47f8ff28ad60dfad0fccf422b0bd" + }, + { + "path": "/usr/share/fonts/gsfonts/URWBookman-Demi.otf", + "size": 97208, + "sha256": "ab8b2fe57cbea9783c6179188dc013c76cfc5717c22cf59bbf3016ef2d7d93ee" + }, + { + "path": "/usr/share/fonts/gsfonts/URWBookman-DemiItalic.otf", + "size": 101584, + "sha256": "4d9dbaae8178ffa2f5d557245d09dc968eeac9eed7e453cc2db4cc6a9aacb44d" + }, + { + "path": "/usr/share/fonts/gsfonts/URWBookman-Light.otf", + "size": 98396, + "sha256": "7b1bc8e6a16cef53785ce03d4848f8971f3ba38cd9e266e4786a50e08bd9235b" + }, + { + "path": "/usr/share/fonts/gsfonts/URWBookman-LightItalic.otf", + "size": 102764, + "sha256": "ed722e6c2996b08e8516d27a7c017972cfabf5d1736b496b6526da78fd17691a" + }, + { + "path": "/usr/share/fonts/gsfonts/URWGothic-Book.otf", + "size": 82968, + "sha256": "04318316cee29950805110c9c8949eea189ef5575132881f4d4d7e03e5299903" + }, + { + "path": "/usr/share/fonts/gsfonts/URWGothic-BookOblique.otf", + "size": 85336, + "sha256": "ec4e947734950fd344918e560bc8ad0c5d8b17d0ab4853988824f13b9f77e53f" + }, + { + "path": "/usr/share/fonts/gsfonts/URWGothic-Demi.otf", + "size": 83580, + "sha256": "5b009410cf5231dcb1e45b155c1afedcfc63d82042fd8c414d0dd7705c9fbbae" + }, + { + "path": "/usr/share/fonts/gsfonts/URWGothic-DemiOblique.otf", + "size": 86232, + "sha256": "d57abaa6d40718cfbf735ad50080d0b095e7194e451992e02786a7c0113db35c" + }, + { + "path": "/usr/share/fonts/gsfonts/Z003-MediumItalic.otf", + "size": 114052, + "sha256": "a04947e59fc9339ea3c4b34c31fd46764f22af7512afc2c074ce190e79ed7a09" + }, + { + "path": "/usr/share/fonts/liberation/LiberationMono-Bold.ttf", + "size": 308068, + "sha256": "1d8d631105d4cb6c562a0d90f591d6393007d71350c089aec81c37dc8da293ed" + }, + { + "path": "/usr/share/fonts/liberation/LiberationMono-BoldItalic.ttf", + "size": 284184, + "sha256": "bac30bbb26308b1a925d233a520fa764c80526f632b75defbf2b37e402eb6b1a" + }, + { + "path": "/usr/share/fonts/liberation/LiberationMono-Italic.ttf", + "size": 281608, + "sha256": "02312ecd02a6b7a96db60deb20468430abb7d3a91974ef9a90bd46ff78ea338d" + }, + { + "path": "/usr/share/fonts/liberation/LiberationMono-Regular.ttf", + "size": 319624, + "sha256": "47ed5b5fcfe6b3c9228937b05de9c769f5fa55b777d539af0f65172f0a24c90b" + }, + { + "path": "/usr/share/fonts/liberation/LiberationSans-Bold.ttf", + "size": 414568, + "sha256": "769673c4355020b1e28a14c366a152da410ab6b16239fe883ebc35b73624835b" + }, + { + "path": "/usr/share/fonts/liberation/LiberationSans-BoldItalic.ttf", + "size": 409108, + "sha256": "4b3d18cbd1b8c068115be4072f43418b194994313f47dbfd3588808903a55d34" + }, + { + "path": "/usr/share/fonts/liberation/LiberationSans-Italic.ttf", + "size": 415920, + "sha256": "8649cf5f2cbe4727549d78b714a3a7ee540718a13557769c4612fea105294c7e" + }, + { + "path": "/usr/share/fonts/liberation/LiberationSans-Regular.ttf", + "size": 410820, + "sha256": "baccc64becc3eb7d104b7c84d99f5314a0a1f896e2b3ea6c2f22fc08d2003bee" + }, + { + "path": "/usr/share/fonts/liberation/LiberationSerif-Bold.ttf", + "size": 370196, + "sha256": "28f2d4300ee366d1ff9ca95df967a27e77987c87857fad0d9c85034405aae39d" + }, + { + "path": "/usr/share/fonts/liberation/LiberationSerif-BoldItalic.ttf", + "size": 376892, + "sha256": "8de6363e9ca6c1e6f539ab032bd9ad6abfe138638f4fc53f3c26ea788b7724c5" + }, + { + "path": "/usr/share/fonts/liberation/LiberationSerif-Italic.ttf", + "size": 375760, + "sha256": "c7681827cba3bddb54f4673d0052cb944bdeac0b83c29c238240617e699311da" + }, + { + "path": "/usr/share/fonts/liberation/LiberationSerif-Regular.ttf", + "size": 393692, + "sha256": "86b9ea1c2f41bed9d7c09ccad4abc2894b33df5de60e5bbbece5d48610911870" + }, + { + "path": "/usr/share/fonts/noto/NotoColorEmoji.ttf", + "size": 10673480, + "sha256": "72a635cb3d2f3524c51620cdde406b217204e8a6a06c6a096ff8ed4b5fd6e27b" + }, + { + "path": "/usr/share/fonts/noto/NotoFangsongKSSRotated-Regular.ttf", + "size": 633564, + "sha256": "bd15a38df12bf78b3481f3057ba266da54f9b671ea40858f993fe57741dbaef2" + }, + { + "path": "/usr/share/fonts/noto/NotoFangsongKSSVertical-Regular.ttf", + "size": 221056, + "sha256": "3084bfebe58ed00c2ca464af861dc6c21d38e986e981b76829f895ab8131ca59" + }, + { + "path": "/usr/share/fonts/noto/NotoKufiArabic-Black.ttf", + "size": 262424, + "sha256": "d1a3917ebaab60545b344ba0f473ba16d0d3e51920e4675794e3784c48fbfc53" + }, + { + "path": "/usr/share/fonts/noto/NotoKufiArabic-Bold.ttf", + "size": 249672, + "sha256": "1feda69b8b77a5efd9c55f03081f309c8776b08030296638c519d154d9971447" + }, + { + "path": "/usr/share/fonts/noto/NotoKufiArabic-ExtraBold.ttf", + "size": 260296, + "sha256": "916038d500b09d469cd0fdb9a4299112a096f3b3861c520dff5a48c7f967629b" + }, + { + "path": "/usr/share/fonts/noto/NotoKufiArabic-ExtraLight.ttf", + "size": 207832, + "sha256": "348b149c6bbdc72c563e80be5a3767a5c2128bcac8f194ba2b29857447b84653" + }, + { + "path": "/usr/share/fonts/noto/NotoKufiArabic-Light.ttf", + "size": 218736, + "sha256": "871a5aee6a82b798eb457aac0ad81966f24a8102e32aadbe7c6b4a0b9ce6d3c9" + }, + { + "path": "/usr/share/fonts/noto/NotoKufiArabic-Medium.ttf", + "size": 235388, + "sha256": "ab5ad910aac78463cee03ea6524a0ce1fbaa381bd405d957cdad0cf37f5167d4" + }, + { + "path": "/usr/share/fonts/noto/NotoKufiArabic-Regular.ttf", + "size": 227732, + "sha256": "76f539d38a44907365dbccc98466c9c607e74abbfd209add21a254a8ce8668da" + }, + { + "path": "/usr/share/fonts/noto/NotoKufiArabic-SemiBold.ttf", + "size": 237588, + "sha256": "e151c1794b4349b5e01ce86df8b197da11e009829d39be89f9bf0ad617a1131a" + }, + { + "path": "/usr/share/fonts/noto/NotoKufiArabic-Thin.ttf", + "size": 212912, + "sha256": "bf6b3db03d111f9fe16a991580a6c488b94d8dd2f9394f5d152a9aab229cafa5" + }, + { + "path": "/usr/share/fonts/noto/NotoMusic-Regular.ttf", + "size": 82308, + "sha256": "fda9c3dcd4164edd5af283b581f17458ef55dc205b6e0607aa0fbfbf99ed5704" + }, + { + "path": "/usr/share/fonts/noto/NotoNaskhArabic-Bold.ttf", + "size": 279468, + "sha256": "04017b0ccdd1156dcde3bc386734c1b03165261762f16a025c8afc9088ae76e9" + }, + { + "path": "/usr/share/fonts/noto/NotoNaskhArabic-Medium.ttf", + "size": 264216, + "sha256": "ab1b4aa0f0856f0a722d229d5cddd465012c3acd11e22fd9512682098b7a66dc" + }, + { + "path": "/usr/share/fonts/noto/NotoNaskhArabic-Regular.ttf", + "size": 247336, + "sha256": "6f0a92031367b2f5a2078fe9d24f3433122b61a0bad57c423aad8f3c39aa2e6e" + }, + { + "path": "/usr/share/fonts/noto/NotoNaskhArabic-SemiBold.ttf", + "size": 265016, + "sha256": "76616e51f5ff1cf4d81a14df6049646911cd4d7e568903a011711c241991624c" + }, + { + "path": "/usr/share/fonts/noto/NotoNaskhArabicUI-Bold.ttf", + "size": 217696, + "sha256": "d2bd1671179c59bb8c3d790eb2c0840e732d37fa1f5577ff34f26b659995dd17" + }, + { + "path": "/usr/share/fonts/noto/NotoNaskhArabicUI-Medium.ttf", + "size": 218220, + "sha256": "6c4b33f5f21f50d4a3690aa7cac7cb6cc0a4dcd0bd31611bea0e21fac17bb5f8" + }, + { + "path": "/usr/share/fonts/noto/NotoNaskhArabicUI-Regular.ttf", + "size": 210728, + "sha256": "e9f881051a2823b879988727913221bc2d063e9142de8c794387ab46656b6248" + }, + { + "path": "/usr/share/fonts/noto/NotoNaskhArabicUI-SemiBold.ttf", + "size": 217732, + "sha256": "cb1f02aa18632dcb69df62687184918f3ca79c8d8f90ee7da863b3d41069c9e3" + }, + { + "path": "/usr/share/fonts/noto/NotoNastaliqUrdu-Bold.ttf", + "size": 254944, + "sha256": "7c9e53eb934043adda6a5da5d993fa61d64dd1123d5227c4b63a5b36ae46ab77" + }, + { + "path": "/usr/share/fonts/noto/NotoNastaliqUrdu-Regular.ttf", + "size": 253396, + "sha256": "4d05b6821862ca1f3c80c47c80a352b47dd349ab73814f60e377eea311e0e42c" + }, + { + "path": "/usr/share/fonts/noto/NotoRashiHebrew-Black.ttf", + "size": 27000, + "sha256": "0327f6eb92b9a4b58dab37844bb912720acb81a2319197cff0bef596b96396fb" + }, + { + "path": "/usr/share/fonts/noto/NotoRashiHebrew-Bold.ttf", + "size": 26936, + "sha256": "84d4f52b1ae45efee282838673b0c76a170d13b785631a1918353cdafd9eb43a" + }, + { + "path": "/usr/share/fonts/noto/NotoRashiHebrew-ExtraBold.ttf", + "size": 27580, + "sha256": "148cfc43ccc6f003d2b0f39662ee33821057c947e6a54bdeb6139613c2ab23e1" + }, + { + "path": "/usr/share/fonts/noto/NotoRashiHebrew-ExtraLight.ttf", + "size": 25200, + "sha256": "bfafae138d40aff751f80b5e7dd80c79d6864c3397c4613bf7ae82efbdb13bad" + }, + { + "path": "/usr/share/fonts/noto/NotoRashiHebrew-Light.ttf", + "size": 26124, + "sha256": "a755ffbde5323f301b46004690a70b790eba73dd6006982f664d64863f5dcf0f" + }, + { + "path": "/usr/share/fonts/noto/NotoRashiHebrew-Medium.ttf", + "size": 26060, + "sha256": "8c3d9733a84a4aecf6677ef8bc9d98b79d8fb0090908d614fa8075c66ab30628" + }, + { + "path": "/usr/share/fonts/noto/NotoRashiHebrew-Regular.ttf", + "size": 26124, + "sha256": "7c6d4d4d32e6e5a6591ec0fab621e61cc87b8462e783c30ebda978dfedc46cc8" + }, + { + "path": "/usr/share/fonts/noto/NotoRashiHebrew-SemiBold.ttf", + "size": 27032, + "sha256": "3462fb0472e0861253cdbfc3b7ac7eb12f32c6ff481273d0f384c9a67244813e" + }, + { + "path": "/usr/share/fonts/noto/NotoRashiHebrew-Thin.ttf", + "size": 24972, + "sha256": "82906ea5464c3ac3de6c37e4a6b077cd13c226e7b05484cd721721b6ab519b35" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-Black.ttf", + "size": 646360, + "sha256": "5d4d9e0ded710a4603e10afebfce2c2d56fa0d216890e8915511d2a53a004c1c" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-BlackItalic.ttf", + "size": 657160, + "sha256": "828d361910e9e3f7e323b73cf6de65f9117a4eea5283f5a92a8b214029a593cc" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-Bold.ttf", + "size": 631484, + "sha256": "1df075a380fc7cb898acf64c1f7b3b4dd780de3caa860178bf929de35817a913" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-BoldItalic.ttf", + "size": 646092, + "sha256": "1b602a9d6353be42c91df097a4857b69fa2696f26703d7a33b54a15d87c2622c" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-Condensed.ttf", + "size": 617784, + "sha256": "806816f70ef268abf388acdacf6b2e169f58f70c9f063515eef16a1f256eace6" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-CondensedBlack.ttf", + "size": 638760, + "sha256": "279ce2b4aae61db0ad8edd5fcca51786628f41f5f7e0d9f0bbf4ca5315f6a4bf" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-CondensedBlackItalic.ttf", + "size": 656628, + "sha256": "064c121007728da7cf7fad229f23dfb06c201238b229137b190ae7707244d0dd" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-CondensedBold.ttf", + "size": 627132, + "sha256": "d2b9ba877a743185a4123c6a82bf22e8b5c1d79bf7c6227d8f1df1857f71159e" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-CondensedBoldItalic.ttf", + "size": 646592, + "sha256": "4f3bcd3ae0a85a1b7fb744a92bcbf0439965d141dac91e5d7ef67db23c5fddf5" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-CondensedExtraBold.ttf", + "size": 623368, + "sha256": "e8411ed0b4635955dcaf8a237930c14f391fb1cd248544131a7dda138e71e197" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-CondensedExtraBoldItalic.ttf", + "size": 641856, + "sha256": "1090d3c54b6bb00fe0c8ba20734049dcdbc1fb2f08d669486d4090f600d5ba23" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-CondensedExtraLight.ttf", + "size": 600096, + "sha256": "953af378bfb4373a85843577dff1b0467498db266caf8b52315050331185d7cc" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-CondensedExtraLightItalic.ttf", + "size": 620008, + "sha256": "31d43d1e986e1b8e4f1359d5a49a96329824acdae72b7da039fd2c96fcfe434f" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-CondensedItalic.ttf", + "size": 641348, + "sha256": "f9345cb19abfadb61f285795a091ffc60a15e9f2653ed866d1e06678fe571bc7" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-CondensedLight.ttf", + "size": 608772, + "sha256": "323f1ede6fa0ee98c9e0d735b9b6387a6f930c7236cc4fdddac9b908d8e09619" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-CondensedLightItalic.ttf", + "size": 627836, + "sha256": "601d8d2d4ea8b1fc47fae925021e2754d2cf1273767492fe59270763b39baccd" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-CondensedMedium.ttf", + "size": 614848, + "sha256": "e367c9cff3acf134f7f12a6ae7ce876d6dbe3aaa2f70121bb1f9ab66457a7196" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-CondensedMediumItalic.ttf", + "size": 633852, + "sha256": "e3f4a5a480a72e9cffdc537edafda08a37e8217be0286b97be9400edcda5c420" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-CondensedSemiBold.ttf", + "size": 620208, + "sha256": "55ebf2188d1b9ff6b09affe3783c7704f8bd627586bf0a097ea4f6a8842f8cce" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-CondensedSemiBoldItalic.ttf", + "size": 636604, + "sha256": "dbcccab18a767e05c545bf552f66eb1658511e1a20bb8a2f0007d1373f540ef0" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-CondensedThin.ttf", + "size": 597880, + "sha256": "477b22ad2f1ebd98904984fc8794cb7d57eb97f1d9fd988f234dd779842b38da" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-CondensedThinItalic.ttf", + "size": 621148, + "sha256": "e07157ced7c3922e0a16d95609f0605fb8a47afe4256dd7c661c46ba627fc796" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraBold.ttf", + "size": 629672, + "sha256": "9f8776ead0801ab77a3d8b372df8a57a4405ea2caf945f9a66007d6b71448a60" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraBoldItalic.ttf", + "size": 642192, + "sha256": "45d0ca53e9597ec4b2b3fccb674a46ac7368dc34045d664328c4a64c61054abc" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraCondensed.ttf", + "size": 613620, + "sha256": "24c594fa365e2f06ebc5872d2e8c3e5af88cfdecb0aa4b86f5bf4c0c270d5223" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraCondensedBlack.ttf", + "size": 633208, + "sha256": "6833b9f1ebad7bcb3dd0bcdc7898cd603024a9ce0261c97cc1e0c54ea7d0b6d1" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraCondensedBlackItalic.ttf", + "size": 657620, + "sha256": "0665735f55857275967427b4958b31d0dcc1c16db0c3729ec0b54594ea20f877" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraCondensedBold.ttf", + "size": 619680, + "sha256": "24c39c3a2c56b1f7fad4701f51c29c9573953acd4178af6ffecb5d49f9c42517" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraCondensedBoldItalic.ttf", + "size": 646080, + "sha256": "432557d0b6807bb1f05166d3ea70a7216291f5fe231b8ebeede82b4ebd204565" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraCondensedExtraBold.ttf", + "size": 620672, + "sha256": "ad4a7a5254d633536e1c5c7bc4aff3571460491b047e9d05e6445a3894a4048d" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraCondensedExtraBoldItalic.ttf", + "size": 643284, + "sha256": "680516ddda1121cbf8bcc9c8e8f60486d1c1f8a6dab7b4e8d3cadd330bc57b84" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraCondensedExtraLight.ttf", + "size": 597712, + "sha256": "c60fd1082ca470d3fa1897a43d62966e46bee5cf6569da5915755a7e4ac6431f" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraCondensedExtraLightItalic.ttf", + "size": 618992, + "sha256": "3a4b614b857af42d6a17a5a76b9bbeb2e91665a50ced5f56fb1fd95afb5d0627" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraCondensedItalic.ttf", + "size": 637400, + "sha256": "bd6c2aa896b9b7414257e2f63312930a225161e8caae8b99dfd01c124f302366" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraCondensedLight.ttf", + "size": 605692, + "sha256": "1f9d72f96082251b833db5969f6f70144172b0085a6056568d8c9890697f8bb0" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraCondensedLightItalic.ttf", + "size": 625580, + "sha256": "59ee1a1225096ba3779cabfdaefaf802bd74a96b870b7306a2e798dddb60c1cf" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraCondensedMedium.ttf", + "size": 609868, + "sha256": "bff5706085b9eb32d22af680e73eec19a1db9bb43042040be40347c69d2ba73f" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraCondensedMediumItalic.ttf", + "size": 633284, + "sha256": "eee2c290c2414cb455b56198e15d83c2e8575ad84948adbe973a6a450dead7bb" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraCondensedSemiBold.ttf", + "size": 613724, + "sha256": "8c73251c9b1fe075c4f0d0cd76864c940b05cfd1042eae5d621eb8d377089253" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraCondensedSemiBoldItalic.ttf", + "size": 636008, + "sha256": "5cefbf5f3eac39fff9fdbc0a4fbd8f719cf593d44d2d1beed32137cf6dc0e886" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraCondensedThin.ttf", + "size": 594956, + "sha256": "8932a083d57a8eddd16b35415c591180120dc3ccdc0ff3d07ed18a64872511bc" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraCondensedThinItalic.ttf", + "size": 616536, + "sha256": "51dae70afadd77150c647260037d29359cddef56ca16fbb1e2eecdebbc5864cb" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraLight.ttf", + "size": 599200, + "sha256": "92eec20dd0a8ca60e0b783d4215f66a02ab34852723cb627179c503de2721292" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ExtraLightItalic.ttf", + "size": 622148, + "sha256": "e7fb364b76d779219c18a57e0b8fc7603fc049d3af5a0f4094b58b9ff547479a" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-Italic.ttf", + "size": 639124, + "sha256": "467e3f89eeca4108bb8710a2b9e0cf2281ac56d5b0609211a83776d0505eecb5" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-Light.ttf", + "size": 607136, + "sha256": "8bf10e8d399c5b70554eb76fe2629899a1098f1a8acb599804b76c619845ce0c" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-LightItalic.ttf", + "size": 633152, + "sha256": "d87516896610c58ad9fcc4c7a75d84e98917941975a74b932b894f0d7126be44" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-Medium.ttf", + "size": 619976, + "sha256": "635d93d1131d791f2576de90b3bb0f7cdf61929906e8420a61b5f7f8e76420bb" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-MediumItalic.ttf", + "size": 633260, + "sha256": "2c103ff3735d1adffe0cc07f5121492a1ccbb271f46e54bb35d753445b5b3693" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-Regular.ttf", + "size": 621572, + "sha256": "478c558ea716033cd60c03438f628dfa75694dcf6b5f6d505a2f05fd2b4f3823" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-SemiBold.ttf", + "size": 625052, + "sha256": "a4e91fd530ac2b4ef5367240144ff37d7d65d66cf76f2e9a2187b93c676f92d0" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-SemiBoldItalic.ttf", + "size": 637516, + "sha256": "62b9a66fb79be097ace62febeb884eeaec0e7440cb9e92f5c31db4eeae263e64" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-SemiCondensed.ttf", + "size": 621792, + "sha256": "08197b0404ba2c8cec3c75ed58a94ee80f8101ceb358d976329a5e55499c30a4" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-SemiCondensedBlack.ttf", + "size": 644844, + "sha256": "76eff3699a80aa5b0b2e918662d5156426069ed8c5db85db988bb74427bf9dff" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-SemiCondensedBlackItalic.ttf", + "size": 660280, + "sha256": "57a77b3fee4a03c5e264e88a1cc96fe07213bd547e736bfe44d79639e4f8fe7f" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-SemiCondensedBold.ttf", + "size": 629220, + "sha256": "39356e3bbeb467f7a6994b0c98d2ff0a5cdc3485b1654c14c2ee1b000589f37b" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-SemiCondensedBoldItalic.ttf", + "size": 647560, + "sha256": "b991758831c7d2c96b941dee167350f01e01feb20f853e1bdb704b2a0de2d245" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-SemiCondensedExtraBold.ttf", + "size": 626812, + "sha256": "c3dacd4f9eff26ab3e22ad3794884a3c91bca336c15e3a3fcc9507025712932c" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-SemiCondensedExtraBoldItalic.ttf", + "size": 644180, + "sha256": "8b7146b18f9bfd7ae0e03b0777545312b11922af69dbf3c2fec2efb61beb825a" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-SemiCondensedExtraLight.ttf", + "size": 601876, + "sha256": "2da4f44af726c6fce7a977757e6c4905bfc38514f6ff8ac8958f6452beffb9d5" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-SemiCondensedExtraLightItalic.ttf", + "size": 623016, + "sha256": "eeed3eec911733f1f3a4f48f7b111a8e4568e3a05d1233c9898852e4cbcbe3b6" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-SemiCondensedItalic.ttf", + "size": 643340, + "sha256": "0453765a6b7ed25319a535f096e962d76b3d2f8991a5157e9b4d90ec8920aae0" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-SemiCondensedLight.ttf", + "size": 610232, + "sha256": "d691a8924cba8070a78f227d71ac5d2ac2c79ad085338888eb314e507d7a587c" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-SemiCondensedLightItalic.ttf", + "size": 636212, + "sha256": "f974d3cc68c6811148d696d65eec767018bc4d0c4f75a6b9a8566cf6b4e56aab" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-SemiCondensedMedium.ttf", + "size": 618192, + "sha256": "c6c52df92db42f17962a5c4c14d96f46c07855974f56d1599855c956c927b774" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-SemiCondensedMediumItalic.ttf", + "size": 635380, + "sha256": "b3468205b3d90e6c17792df26a5caa04594a2a58ec7da36b80d5fb54f7788afb" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-SemiCondensedSemiBold.ttf", + "size": 643992, + "sha256": "93b4a7a5fa59470285b4c189366eb7cc3ab5c9ebb738e592292ac5ea86ad6874" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-SemiCondensedSemiBoldItalic.ttf", + "size": 705292, + "sha256": "7fcace833a6e46f319bd4c696004c53571526dde0ea5ccc540be5224f3d93fe5" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-SemiCondensedThin.ttf", + "size": 601344, + "sha256": "eed1383473d36f482b67c48bfe81ecbafa51c964d8bbb970708be258e1659377" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-SemiCondensedThinItalic.ttf", + "size": 624004, + "sha256": "454a89fee993df369e1356b6c0b44d7b8014e65f280dda7a43f87f3d18111d05" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-Thin.ttf", + "size": 597616, + "sha256": "ebcc5796482176cca93418db799297554fa9f739489e81fb707990d9300841cb" + }, + { + "path": "/usr/share/fonts/noto/NotoSans-ThinItalic.ttf", + "size": 623584, + "sha256": "7d61c442bb785d16a22f3acd436e953bef5b24021137bc0c2a14c560a074ab02" + }, + { + "path": "/usr/share/fonts/noto/NotoSansAdlam-Bold.ttf", + "size": 93272, + "sha256": "7052be8c096a07c38bb13bfa3f9f4ccfa41145f22195c8a2cb74b802736fd5b5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansAdlam-Regular.ttf", + "size": 93364, + "sha256": "4b51541536a7b28142a4571c16dc56e386c05f345ac9739ef8599d8ad4ed5e54" + }, + { + "path": "/usr/share/fonts/noto/NotoSansAdlamUnjoined-Bold.ttf", + "size": 36432, + "sha256": "267a82265260e4fe089792d7c6071e8c052e988b6ae360ef2f68a27863b8f750" + }, + { + "path": "/usr/share/fonts/noto/NotoSansAdlamUnjoined-Regular.ttf", + "size": 36352, + "sha256": "b711bce34b203ac8c008af4e0c4fc071926a632bad5bfc9c461deb13cb1baee3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansAnatolianHieroglyphs-Regular.ttf", + "size": 228120, + "sha256": "c7ca66d66264d27f9995a061d4045b4e4a25ea1958acae0f414d0e22992a3546" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-Black.ttf", + "size": 264268, + "sha256": "0fe7af5d3e213ac17d587214de7bddc9161455b06e87b21bc55cbf333959eb69" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-Bold.ttf", + "size": 261460, + "sha256": "4e5462d2e8be880317b9f49b5b2da109ddb6a3563d91cc604b67f3535832a555" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-Condensed.ttf", + "size": 236980, + "sha256": "30d0d224683903791f77a026c573e328abc07b9ba5f71bc0ccbbf9a647370e84" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-CondensedBlack.ttf", + "size": 265920, + "sha256": "a6b29c9f409f1c0ae1d9291ea5f4cac32744e36e6ff4edf3ba15e708df693480" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-CondensedBold.ttf", + "size": 260700, + "sha256": "8286709fe36d38d8bfd7938092251feea82f6eff0bfe3dd86d7f3e2afe95707f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-CondensedExtraBold.ttf", + "size": 261804, + "sha256": "60bcd2f420074a4db33af9991a56884eba71f830c015d11c4c3e2a42c5981a71" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-CondensedExtraLight.ttf", + "size": 219056, + "sha256": "1789db05ee9d2514dbd3a022933ed1df20f0b5a6619b453fd40c90b7a829b113" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-CondensedLight.ttf", + "size": 220884, + "sha256": "7205741219d2814b0c9a5c29cdf1c6d9ab35ff74b297ca984bdb199b74176a96" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-CondensedMedium.ttf", + "size": 254180, + "sha256": "a4eceecefe35dc9c1c806fe46def4dd49ed6c20b163a622f2a309150d8504936" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-CondensedSemiBold.ttf", + "size": 254784, + "sha256": "ce439df899cbed568917dea338e901fa09a738ee21dbe224b3d839ffb3c5fca4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-CondensedThin.ttf", + "size": 212020, + "sha256": "3733d6977b9f1f1a1ff854fcff9cdc2a0546399ae4b51d369a9051bc5c300af4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-ExtraBold.ttf", + "size": 265848, + "sha256": "880f09e31d61d87fdd6feaae3330a26d6c857aaaf918f4e893a952fe7a1d5545" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-ExtraCondensed.ttf", + "size": 229200, + "sha256": "b3f8f75686d22974d61e7780fbcd485a5855d8e5cba347170923b07d0f257f9c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-ExtraCondensedBlack.ttf", + "size": 263640, + "sha256": "1d8884161e38f66b0486ff630387f359272945d617211d80c31be55465267b2b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-ExtraCondensedBold.ttf", + "size": 258656, + "sha256": "8dd3f7ce82f83c6fb979985b8fb36dd5c8d6be94fc0979d88f46c63e76d6b104" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-ExtraCondensedExtraBold.ttf", + "size": 264904, + "sha256": "bad3e5ffde5ff270918562c51cfcbca3ea1585b2d6a3feb84ac0c2b91b5aac5a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-ExtraCondensedExtraLight.ttf", + "size": 220856, + "sha256": "bfa86ee5ce23b9d30aa4ce58a27f331981bad540ebdacff5fb57f8fcc02365c2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-ExtraCondensedLight.ttf", + "size": 229228, + "sha256": "a7e208c4d5b5fe989975815f2d1149108768125abd563bef7bf393817a352429" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-ExtraCondensedMedium.ttf", + "size": 238944, + "sha256": "70d1c2a6d88c645bf1ba44bed00b38a2e5ccf18640618cba8d095cd023a88d0c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-ExtraCondensedSemiBold.ttf", + "size": 251208, + "sha256": "6ed5cfb6b74f6e7a7646ed5bdd7b9428d1ad6ceb6f569e8729781f58b401ddc5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-ExtraCondensedThin.ttf", + "size": 212936, + "sha256": "7664810a7e9c0f4785b0bdc7f7cf06250cdcb7aa214b403726cfb7e85a1c9a35" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-ExtraLight.ttf", + "size": 217820, + "sha256": "0965c02426887ff0859c65e0c782d8d60a967c85cfbcf0c2c62dbfa5492c060d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-Light.ttf", + "size": 224604, + "sha256": "9cb5537575fbf84b250ea4478e6906fe32add7c3eca857808abc35ff7752d053" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-Medium.ttf", + "size": 256492, + "sha256": "5aaa32f0fe7fdd828342aea06a3c645edb51bcea2620af68b4c385a3b9fb0c42" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-Regular.ttf", + "size": 234892, + "sha256": "bdff3e5659d67e67def05b33f749683b9376ae819d65d3dd62ac4640b3aaef48" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-SemiBold.ttf", + "size": 250512, + "sha256": "c3fba0d014108e86247cf19ea2fd7f28e53b13f0f6180c755a2b4d7da404d18e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-SemiCondensed.ttf", + "size": 244936, + "sha256": "8d4f48b8ebedabfb4757bc54e5a9d99db2b7e0ef18704dde9c45343668422418" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-SemiCondensedBlack.ttf", + "size": 265524, + "sha256": "a5fb5500057cec23297b0d27feebcd8b1c1150114d6e5297bfc3ed75df86716b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-SemiCondensedBold.ttf", + "size": 256936, + "sha256": "eb461341ce67b9e2b473bbbd77ea2b1e116c235bda6310c5baaffb40dee99b19" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-SemiCondensedExtraBold.ttf", + "size": 263924, + "sha256": "e5b5c2c271b84c9e8432a54db6c3f311986943f6cd7ab31c3ebe200c6be982b9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-SemiCondensedExtraLight.ttf", + "size": 220908, + "sha256": "0bbcd90a86d53120748267d54b1b942b7dba722c64eb71a9f82c145fa0ac7472" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-SemiCondensedLight.ttf", + "size": 220124, + "sha256": "edcb2af556bf0452dfeccf529f4e1b332fe9a7ae8948f87aa6c0424bbcdfcfe9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-SemiCondensedMedium.ttf", + "size": 252292, + "sha256": "06f43b71238a553a2b05171a8f28ee0a312ded359b9b7bcef1c8bc7e7ee7327e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-SemiCondensedSemiBold.ttf", + "size": 255168, + "sha256": "65d1eee18072f2a63fc04ea9fe75c06efdb89109f477777496fae1cede459233" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-SemiCondensedThin.ttf", + "size": 212652, + "sha256": "332a1915bbf5f96efd442e31494c772ab566aa2d576c816ddfee982e7e610f0c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabic-Thin.ttf", + "size": 214732, + "sha256": "abb8a70202d50d596cdaf61d149f0d2b470e7f128eb262b340ef28157abd290c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-Black.ttf", + "size": 301580, + "sha256": "68e8f3244b5ba6b2be031ef0c184e1739e638a4ad7f8fa45a9dfab8ecd01cd36" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-Bold.ttf", + "size": 288428, + "sha256": "ba511a9cf3712cc801203f5fcaf5b35221830f975ec0fc91678e9a4ed07a1f6a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-Condensed.ttf", + "size": 275808, + "sha256": "e5fa7fad739b1bd299e24cac19c811f621360c82807402919786b8d4d9cf165a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-CondensedBlack.ttf", + "size": 297388, + "sha256": "0f3011235eb22b9283f4fa30a52e44e00819bdb62d692f03d405e45f0e6bff28" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-CondensedBold.ttf", + "size": 290696, + "sha256": "091e2934ae0a9980c9750b62d4d17316fdeb754a529e02e2f7d2d8c929e010c4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-CondensedExtraBold.ttf", + "size": 297424, + "sha256": "25199398aa1a500cbd1aad29908e5503d3829a8165bff91b739925b3c3269925" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-CondensedExtraLight.ttf", + "size": 269916, + "sha256": "5eece8ef39c0a0f5c7d7226816142968e0625625b1b3a9491ab0f87902f83b3c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-CondensedLight.ttf", + "size": 269348, + "sha256": "8da7652c3154980b0ff53d29d7b94bb175df0e13a33160cf8c12363acbb66d48" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-CondensedMedium.ttf", + "size": 288544, + "sha256": "c46fc0da7482c87a4518f07f89248a7bedf9f10f46c64e26d94cc514e6d1dbf6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-CondensedSemiBold.ttf", + "size": 289904, + "sha256": "aaccd5284ea4d0a5cd8e655034e85b676fcda286afb6d556cb6069d6cde4a9a9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-CondensedThin.ttf", + "size": 265864, + "sha256": "7bbb954a8b48ebd025a3b4da5eefc89cd207f61329d2fd8198d9d4c2f229c8b2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-ExtraBold.ttf", + "size": 294668, + "sha256": "045a58b641892ffa43737e7ebda87eecbfe7d84178c895140122227abbc62702" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-ExtraCondensed.ttf", + "size": 272988, + "sha256": "689b4e3579915a36bee5dec9679acb68639fc1dea2c38ef73b97f44a9a28b23c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-ExtraCondensedBlack.ttf", + "size": 293592, + "sha256": "8d6fd49617fe8ae8443849bfccf2be771b028d1317ea773a3ca692969706e25b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-ExtraCondensedBold.ttf", + "size": 289608, + "sha256": "5e561f6ab33fbfb2cd0d99947eb1eed098706f9d31be7d4a4738de898583ab0f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-ExtraCondensedExtraBold.ttf", + "size": 293380, + "sha256": "e780ba109d894449efd4b977d218f47feb6914f5da890730bf13b057bd41ef17" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-ExtraCondensedExtraLight.ttf", + "size": 268768, + "sha256": "bcc328e3bdebbf3526b38786fb9b6ba0bb652554b406c39b3030b0643f62e177" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-ExtraCondensedLight.ttf", + "size": 265000, + "sha256": "f3672a2876a583294c98b116cb9ee9c53686fb95ad47535dc51a4f3b9a47dea7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-ExtraCondensedMedium.ttf", + "size": 280940, + "sha256": "0d3169a70541f4f7a8900750dc92d3ee8010f7f5606f6da179ab2f2bf1b2e9c4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-ExtraCondensedSemiBold.ttf", + "size": 287400, + "sha256": "7d5047714fc7e3b8fffff2b82dcaf28f300a6813438e2dfa720e283dbeab7fa7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-ExtraCondensedThin.ttf", + "size": 263488, + "sha256": "1f007255a9f31ac15517b22043189911f8cd2cec0000d459abab47eb4f5dc810" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-ExtraLight.ttf", + "size": 270192, + "sha256": "9aa9ddb1dd8c8b841a4ffdaa3713910dfa91a03551bc408d01d44faf25f47b7e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-Light.ttf", + "size": 267432, + "sha256": "e97f9a95b750bbce06e6ca3bc2f98744fb6a2d6939fd4ed8b33d0dc738dd9a15" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-Medium.ttf", + "size": 286416, + "sha256": "d8f4ed3443a3822687edd7e7fb73dcc31f594720c69805217ec8f3ceb0ba1e1a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-Regular.ttf", + "size": 274660, + "sha256": "c56275c744ded6ff6df13de04963e6174632f0405a54a83f44d0fe5395f45ae6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-SemiBold.ttf", + "size": 290976, + "sha256": "87e65473f3cd9ad7d39bc06efa13cf3aad3ae5cd9d75c13d60de3cfa5328c490" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-SemiCondensed.ttf", + "size": 277732, + "sha256": "766877fe9cf60bf13d412d10e240e8a102c2443d49d379d1ae705624c0c128eb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-SemiCondensedBlack.ttf", + "size": 297552, + "sha256": "b0da8d8b37d220ad85c804c0d77943f29fed6624f7a80590a4b0b8373a04882f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-SemiCondensedBold.ttf", + "size": 291716, + "sha256": "26813fe66688ab0ea8061e9db2023ace66fedcacd5d2f3d60c7ba3dabb559706" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-SemiCondensedExtraBold.ttf", + "size": 296156, + "sha256": "70199851e8c9baf2f03560f791be21462883010998337068ae568a2e5f6d5a44" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-SemiCondensedExtraLight.ttf", + "size": 268096, + "sha256": "374dc370e87f20e063843e9e4c52270b7742029a500154b425607ea47ea7915b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-SemiCondensedLight.ttf", + "size": 266576, + "sha256": "75acf4fbddc4547a878ffd3ef7c9e2a63e93edfea90599e308961041737cdbc9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-SemiCondensedMedium.ttf", + "size": 286956, + "sha256": "66d33ec1d012e5baf7d530bc5f9fed9c51bf127d730bccd3a301659d79bbf950" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-SemiCondensedSemiBold.ttf", + "size": 289792, + "sha256": "b49b2d834169563787e63d18b90b625dd595862cf34157042906aff184a54559" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-SemiCondensedThin.ttf", + "size": 264532, + "sha256": "7ca85c2bafb26f8ebe0dbd92989dbbf52c70c0406275217d7b1cc0d91040b181" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArabicUI-Thin.ttf", + "size": 266116, + "sha256": "12bad3ef70de2c971f762f43e54ad9aa9ed03ce7f3450535e6b5cd629e2adb7d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-Black.ttf", + "size": 32768, + "sha256": "842d8bd193c94730ded6f5c28b93b4e56e6403dbcc7ec4d4e3f19000cd215eb4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-Bold.ttf", + "size": 30784, + "sha256": "58e4529f7f44d1fcfbd5d81d446b656f1230267ebfa96f8006956f18fbe4bbe2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-Condensed.ttf", + "size": 30536, + "sha256": "cf4c3d72ce7de144c533a54a5389fb7d93e40c8afa3eb98290031c5407a9b0b0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-CondensedBlack.ttf", + "size": 31352, + "sha256": "1a7e0def3a1fd9c96608c56c0e50275805cab8951b1d4d25611a278b55b6b5cc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-CondensedBold.ttf", + "size": 30828, + "sha256": "ab0011fa158023ead394792c3faf102b78ffab12838e957c2ef6326906ae654b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-CondensedExtraBold.ttf", + "size": 31136, + "sha256": "ef98101153c0991769311e2433d389045532c66275817fc66a8fc3968eff2a31" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-CondensedExtraLight.ttf", + "size": 29588, + "sha256": "9d744ed4435e6a7e34884a8adf5f2d31f27db2f8f9f02296dcc2c203bf9748cf" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-CondensedLight.ttf", + "size": 29572, + "sha256": "573e54bb3d4b10185e130503d137de00a4f1b9731850a84d512aa2dd692b09f3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-CondensedMedium.ttf", + "size": 30772, + "sha256": "6a8e028f51451e7e89fe457b4b797755d3611dc6d338438289080f65ff1dd2a6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-CondensedSemiBold.ttf", + "size": 30868, + "sha256": "44dfc2b86c571e84cd91837c61a9faf4736b8139201b06716287b6e32c8764c8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-CondensedThin.ttf", + "size": 29512, + "sha256": "4005a62009155c39fef07071a4dd642e9fde40208fee35b46c8156135d37cbda" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-ExtraBold.ttf", + "size": 32400, + "sha256": "684dfdf4b9c4aa1096266959b575a2896b9502e60122ae4dc8f6b64f63c1b71a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-ExtraCondensed.ttf", + "size": 29912, + "sha256": "f069917ef2cf95224ce619a2792556e1c9e56f8a3cecd4d195758989ff554975" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-ExtraCondensedBlack.ttf", + "size": 30600, + "sha256": "a7eda179e72bd4bc9bf7d22dd4cfee1b62d76e25b729501d3ad525c662bb54d2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-ExtraCondensedBold.ttf", + "size": 30152, + "sha256": "9cfd9166c1efcf23844c2ea9d73bc400657b66c8e0fd164cd34c0dc51aff5ddf" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-ExtraCondensedExtraBold.ttf", + "size": 30688, + "sha256": "b9b48236e4b95efcb3460ea52a5bfc9f70532144a5b4dd719511c816471cad27" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-ExtraCondensedExtraLight.ttf", + "size": 29048, + "sha256": "f1273d93d0c6ede4621888e14bc5f2eba20db4421be72e00d1a449b5cd6d92b6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-ExtraCondensedLight.ttf", + "size": 28876, + "sha256": "a05020f5323d8cd8f64c869215736f915c437febabd4134f3c5fb7be70f34b45" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-ExtraCondensedMedium.ttf", + "size": 30200, + "sha256": "dbf2be0419889b029cd185b2500a92799bb6a5986637b430a7d671397ee5d895" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-ExtraCondensedSemiBold.ttf", + "size": 30356, + "sha256": "02ab5f104e8f22ba8030cacf8e737c6b5fd08f0f27d1d2fc93eb92312071a437" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-ExtraCondensedThin.ttf", + "size": 28828, + "sha256": "daaf3a5aa9bd24f2072e2011d60d3c0a75fb7f37083f5d938e9779d49ef4e2cb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-ExtraLight.ttf", + "size": 29700, + "sha256": "29102254ebd50c382baaa5076a90b65dd5b0ffeb3486b5e33db8f07e6a9d48eb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-Light.ttf", + "size": 29596, + "sha256": "1b46fb1d8aa3bee42aa428b22aef06fdb381d73d49d3bfc14c4270fee4d0f0ea" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-Medium.ttf", + "size": 30700, + "sha256": "d028c91f4ebce32ec708dc9b1697eb7f96ea2c1bb8fe47ff1ffcfde9477fcdea" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-Regular.ttf", + "size": 30752, + "sha256": "720df88c332417a235b4d6209d14ec2e2bf4bfe2a954b7453d869ea593bfce1e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-SemiBold.ttf", + "size": 30904, + "sha256": "7b8779b43036645be7db44317c8557d6f7a329741e9f0de861887a305f2fdeb8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-SemiCondensed.ttf", + "size": 30648, + "sha256": "a86b30034edfcf6bf36fa8e4ff50a6da6d2692550771599711297d01b298b862" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-SemiCondensedBlack.ttf", + "size": 31624, + "sha256": "8b373072d7af5a69a69be66ff590aa26f77116ae54ca7faf970abb8632e3708a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-SemiCondensedBold.ttf", + "size": 30764, + "sha256": "1163835203b7bceff84c9e8fbbdef8aee7910173d9609bbd80a643cef185dcbe" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-SemiCondensedExtraBold.ttf", + "size": 31316, + "sha256": "4e091a7a1b9561fc4d1debc3fb65237a5264c3944e223a69cc1880b6b4939f76" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-SemiCondensedExtraLight.ttf", + "size": 29620, + "sha256": "8dc2cd0da152950a8ac671127afb2cba4fa4f0b9561cc819aab7b5f1769b5edf" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-SemiCondensedLight.ttf", + "size": 29544, + "sha256": "4da794b125a61f48542bdd30735ef25dc29c5d87e88cea88c66d2b1d71bf70a7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-SemiCondensedMedium.ttf", + "size": 30812, + "sha256": "c4926c41096b23654f7931a9ee3fc1fec611066645af96214735bea51d050e09" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-SemiCondensedSemiBold.ttf", + "size": 31248, + "sha256": "61ebbf4258bb40d24ee1d2a8b2793dec309bde507aa81cc7fea64bef61a54a9a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-SemiCondensedThin.ttf", + "size": 29572, + "sha256": "666a30e8b45685a740c485e3e07d6987964fc2c5ca2e8a6f59838c6e9fff1949" + }, + { + "path": "/usr/share/fonts/noto/NotoSansArmenian-Thin.ttf", + "size": 30576, + "sha256": "24827a6c6dc174a66a2387f880e3a689ea09638a0032de6752866304e5e754cc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansAvestan-Regular.ttf", + "size": 22584, + "sha256": "8d10bb90dafaefe8d1258880b9ebf955edf62e48066e96be923dcc695a56d82d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBalinese-Bold.ttf", + "size": 92184, + "sha256": "0152d9070b2e4c36ac2556015c16e907b25ef2486eb11cf74b47fbf7dc96bb97" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBalinese-Medium.ttf", + "size": 91140, + "sha256": "5efa6b35fd47e082d416066549fd0c16399a91aaf9f19858c720cdfb1033b063" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBalinese-Regular.ttf", + "size": 92380, + "sha256": "cee58a184e7e00c4332087ac01870a69ac52fd5fa17ea3783ac728c945af1827" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBalinese-SemiBold.ttf", + "size": 91492, + "sha256": "daa18f0ce26f1bf7b76bd4369b901f1aaab90843237a427ef0c7f5000a3e048f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBamum-Bold.ttf", + "size": 230444, + "sha256": "cefbf4fe5ece56d079995af68ec494cecb81ba7c88b4d7e08d90614e27653db7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBamum-Medium.ttf", + "size": 230228, + "sha256": "180072be450f67d3182d1b0ae771c3494e5b0e27b7bec6bc534f652eb8d5c6b9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBamum-Regular.ttf", + "size": 230008, + "sha256": "0282610e6923d06a4d120cff3824e829b4535a8c4c57c07e11dbe73475541084" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBamum-SemiBold.ttf", + "size": 230928, + "sha256": "90f88b6574f42c75799b6fe861d52fdfe25379b76b506f0e99f5c4d826e4de9e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBassaVah-Bold.ttf", + "size": 7868, + "sha256": "66cd290451bbbf1f4571aa67fd9ab53072b0fff7ac947fac20f4dc53ee077172" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBassaVah-Medium.ttf", + "size": 8084, + "sha256": "4ac08c86c23bcafecbfc3a2c8a93765dbb9719b4f901acd5d39619ed87ed503f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBassaVah-Regular.ttf", + "size": 8116, + "sha256": "8218239377452e05634a91ee8a4338daf0aa96a15673a437533a098eb9c06f53" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBassaVah-SemiBold.ttf", + "size": 8044, + "sha256": "49810f805c17d15029dc14f24297fb742fd70e0cb8f9104b15562e581ef1ab4c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBatak-Regular.ttf", + "size": 21860, + "sha256": "e92e4bffffc64f7265ca9da1097e8b8be31fb963b8148edd06d1a22e1a449160" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-Black.ttf", + "size": 143736, + "sha256": "1431f00ffbbc3b2b7967727d8cc9a77d1497ab5807ebc2e6fa3509b37926e529" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-Bold.ttf", + "size": 144828, + "sha256": "923c6a4c2eb618a57ed83f4bca855b4f5287b87906a4d9df40a2ffa8ebd8c2e8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-Condensed.ttf", + "size": 142496, + "sha256": "781f8625a90148d0bed3c1568a4fe25b182a06ca697f980e12a547ff2bed4622" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-CondensedBlack.ttf", + "size": 144900, + "sha256": "3a7360d2a62d89281927761d1bdb50976db46bbecff2ce6fba59a6667cbd86b1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-CondensedBold.ttf", + "size": 148168, + "sha256": "7aba4efa046bcb31008ca0ea01387072d07cd37925443be609ea94aa3b57cf71" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-CondensedExtraBold.ttf", + "size": 144040, + "sha256": "ae1c149fea3b7b93a994908eb542ed4f8bddc67c2dcf6040b354c6cf86c887db" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-CondensedExtraLight.ttf", + "size": 140380, + "sha256": "73431f642e8c6b60405a8669b9428fcf70a68087956cdb51bb61cbfd86c51d96" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-CondensedLight.ttf", + "size": 140692, + "sha256": "0544659ae7150117c5b26aa43f79f8b77abe70ff5bd0bf77a17e6719a36867c9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-CondensedMedium.ttf", + "size": 146544, + "sha256": "92759ce13c5358e76c757f1ae53455db1176537d9a9834ccb5058aeaa41587f6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-CondensedSemiBold.ttf", + "size": 148084, + "sha256": "e2408830f417ca83fc219b735a95a90ad3a7f467903fc05584f5c147b5bc7983" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-CondensedThin.ttf", + "size": 140632, + "sha256": "0c551e053cd3efc9951c1e2deb0fffef3c508b7f02b85df0e4c1230aa6d092fc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-ExtraBold.ttf", + "size": 145072, + "sha256": "0f722866de93b7a9638c84a7b9ab5edf28a11dbc095408071981872d0aee3b63" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-ExtraCondensed.ttf", + "size": 141112, + "sha256": "c70542b0a2a9175574da4dba825a82fb5dca6c934c28ce5ee8816f694c29624c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-ExtraCondensedBlack.ttf", + "size": 145400, + "sha256": "4b41c31b31699808a907347b9a71e415258a220fc915d66b5282e29f45d33fa0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-ExtraCondensedBold.ttf", + "size": 150996, + "sha256": "cf6291e7088e0bc7e56a9f15f39ead1cfa31b8da6ae172105d94c327b40d130f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-ExtraCondensedExtraBold.ttf", + "size": 145052, + "sha256": "d7bbb24d1bbad26d85351c59b909ca25dff880dfce382861f02bc79ed9547d76" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-ExtraCondensedExtraLight.ttf", + "size": 140028, + "sha256": "ee37d70fd0064484e2d61649ab704ba11e21f88f2e250eaa8f63676e3e1bb4cd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-ExtraCondensedLight.ttf", + "size": 140400, + "sha256": "98173fdad0adfe0cf6e3dafbdcc94a158acbd4974b2287eab6d51258fe84e2a5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-ExtraCondensedMedium.ttf", + "size": 145332, + "sha256": "5ada55f427243cf78aa403400e6105cadc7abeb1f717fb29b5d721754e716b47" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-ExtraCondensedSemiBold.ttf", + "size": 149268, + "sha256": "56f9d11661f6ac6bee2e30a129a79f6f28a1bbb206c1925c5a317be966886c75" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-ExtraCondensedThin.ttf", + "size": 141444, + "sha256": "7cea0366725b09e376ce34b8f9514280485d1eadc68b97d4ed0123c5954d622a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-ExtraLight.ttf", + "size": 141652, + "sha256": "ea2bc1717db7f7448bbf65107fe6b5a4f3181f11cbe67c90052b9e490d5c94af" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-Light.ttf", + "size": 140408, + "sha256": "3c86d6854fc6d24c33088c7d9afaad96ea19ac46acf6261ec497536a7ba9d60e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-Medium.ttf", + "size": 148524, + "sha256": "1359cbbe4866a90e19041e8ae2e53051e5b8d66cb76652826637f487e89302e3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-Regular.ttf", + "size": 143072, + "sha256": "b55c62ee531e3214da6c0701daecea89a52ba42db7d8206b92e6b51f397a3193" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-SemiBold.ttf", + "size": 148884, + "sha256": "77235a5e06ceef2ef4e53e62cbd08c7d1a6523a3034f1c822d5234247984793d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-SemiCondensed.ttf", + "size": 142076, + "sha256": "69633529ba446f5a5fa43a80c212cff90ff0e7eac28f728dd89136c34f6b2101" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-SemiCondensedBlack.ttf", + "size": 144292, + "sha256": "d40cb2374ddcab4414b49d9b390e257430a2c891e817439f73f95997b063e7c6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-SemiCondensedBold.ttf", + "size": 144532, + "sha256": "7708878a9fba3849fdc37e3dd1b9317d85fc54230d0c1f4021f27c665ffa4e90" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-SemiCondensedExtraBold.ttf", + "size": 144724, + "sha256": "0b8fc1956b671e7963e1b35f6506280de32581205d1877433b9b8581d28adfb4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-SemiCondensedExtraLight.ttf", + "size": 140336, + "sha256": "09ae1ec20ce62a86e767e265119decf2fcdb2da8d681d3f74f7a2090a2b00547" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-SemiCondensedLight.ttf", + "size": 140648, + "sha256": "ca76cb45d4db9badf78c056b19575daff94f2dfcbee50cc68d3780a5836e31cd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-SemiCondensedMedium.ttf", + "size": 145336, + "sha256": "2cdfcb1fc576b5b038e50e2fcec67979c545584c379ab5750bd01f560d333bd0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-SemiCondensedSemiBold.ttf", + "size": 147200, + "sha256": "4e745d2f16506e34a1d87fbeba64f107ba47d69468d3a1b98460a469b1ed0585" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-SemiCondensedThin.ttf", + "size": 141464, + "sha256": "22e722117c64dcb3f9f65e9791c93b7758770de702cc5073de020592a9518d23" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengali-Thin.ttf", + "size": 141568, + "sha256": "4081f6f2a55a05fde64a434a0dbc113f4726ba1bc987e63f90fe78fa45554e31" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengaliUI-Black.ttf", + "size": 218108, + "sha256": "777e2e8e60966d87f0422dd19e83667ee79c6aded4d46eef7a52c64bd3600cd5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengaliUI-Bold.ttf", + "size": 212652, + "sha256": "0d46caf69318377337ca4c4e92907af11ea4bdef5465c8d5cfe17f8771d824a7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengaliUI-Condensed.ttf", + "size": 205036, + "sha256": "5fc6e7bf113b0a8d34c535f40b9201f4faac85dc8e1e3163c1ea5138c6a24881" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengaliUI-ExtraBold.ttf", + "size": 212972, + "sha256": "8ef8f627a1259259799d77161a2d449f8d3d6b99271b94ed13372489c3dab504" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengaliUI-ExtraCondensed.ttf", + "size": 206240, + "sha256": "5ea26c8f8976a2c403362a2d2b7fb9e08d69eb32d78762146c48d4515f49bd3a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengaliUI-ExtraLight.ttf", + "size": 207340, + "sha256": "3518235f94b6aa9c3aaf270618ee60743d7717fc20010233b90e8d2a76732065" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengaliUI-Light.ttf", + "size": 195944, + "sha256": "fc46651b3e5074e9e5ee6e9a0e2f55c33219f93424a46fa4dd4250cd00ed3c83" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengaliUI-Medium.ttf", + "size": 208420, + "sha256": "42dae1396b47f3e1d4f8f86b44f63f686fccbbbb00b35bbf6f62373555ab06e5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengaliUI-Regular.ttf", + "size": 203732, + "sha256": "ab2f54c0922024eb8813c3f33e335f137505861dba38d78195cf32b7c3ed8be2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengaliUI-SemiBold.ttf", + "size": 209648, + "sha256": "c250426cfcc9b7616e87be69a70ec8ac4bd6efdb984d9d6827464d63b87151b5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengaliUI-SemiCondensed.ttf", + "size": 207888, + "sha256": "b7b456c23409b04aacde5a430817e586c0a8c573c4038088d4e3de705a72b800" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBengaliUI-Thin.ttf", + "size": 190228, + "sha256": "a3018fc4db9922c647d00436ee822427cfb7125dc49f55c4eefd9c82a74df117" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBhaiksuki-Regular.ttf", + "size": 232572, + "sha256": "3a68dae9462a2b17566460f32caf91ba60a1d8678b4bb86452bb0752bbdd337c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBrahmi-Regular.ttf", + "size": 46972, + "sha256": "5607e874c4793381e22ba7800b748de43f801829e6b9b49f7b7b2cc3973027f6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBuginese-Regular.ttf", + "size": 7232, + "sha256": "bc19f17d7f6e8f280c2cc95ef6d1b67fac25becfe98722f482039a4d84f3c9ba" + }, + { + "path": "/usr/share/fonts/noto/NotoSansBuhid-Regular.ttf", + "size": 5316, + "sha256": "328d80e11e7f65f9b6e4bac12de32b7ce42154301c2a14ba92155e32e05939d6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCanadianAboriginal-Black.ttf", + "size": 171472, + "sha256": "4050373f38bfe5e2fa1308fe9766c9a8c320652b0aa8924b6b6352dedf79af6f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCanadianAboriginal-Bold.ttf", + "size": 169356, + "sha256": "1155b213ea404894d94a310be8d41240ccd8e264939873bdc45b96a3aa21df56" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCanadianAboriginal-ExtraBold.ttf", + "size": 170148, + "sha256": "5b958e85967453dd02865f95413fceadd281ba68a01e47397a0f7ebcf1e845e2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCanadianAboriginal-ExtraLight.ttf", + "size": 168264, + "sha256": "fc642f4f401689e4e5752ae69daa1629806d466610b280161ee00dbeb50c9a52" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCanadianAboriginal-Light.ttf", + "size": 169792, + "sha256": "93e8a3ea6240d70730cf09cf31c290ccfb2349af9d6eb2938289f22c612e22ee" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCanadianAboriginal-Medium.ttf", + "size": 171392, + "sha256": "b8fb267586780ad7c6e85d8dff51cc0b3cb092b7d3fec811cfc3f60aa84f3f7d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCanadianAboriginal-Regular.ttf", + "size": 176372, + "sha256": "6f489ba696faff1d96f8ace395c0d22fe3b82621f69bf50b337112fb47ff1f17" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCanadianAboriginal-SemiBold.ttf", + "size": 174316, + "sha256": "cebbd71de25bb69f71160bafbdb02ebb35149591dd1ff411706bfbdba8e30c38" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCanadianAboriginal-Thin.ttf", + "size": 166932, + "sha256": "320dfaee5b55da8c97486037249e5f3488ca4608ff3a19f23e63ae078168ff4e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCarian-Regular.ttf", + "size": 12416, + "sha256": "0c5ddc0892b44ae1c40fd0734eb2be5b8b73e178f16a475cf1bbb9b5d97d6f1b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCaucasianAlbanian-Regular.ttf", + "size": 24752, + "sha256": "8bd42b9e9a852e38dfc7d6821d8c939697886e691e15cf644d1eccc40f7ac2b4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansChakma-Regular.ttf", + "size": 82348, + "sha256": "60ce1d029e35b432dd68cc9f6c94f69bd84d8c97f28f06130186606dd2c3325d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCham-Black.ttf", + "size": 32260, + "sha256": "63c1c6ff087be23f867c3d115a50e7118b5f6c2e7a0e3c69cdbf74f633bd9af9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCham-Bold.ttf", + "size": 32296, + "sha256": "7a0f4a60ef83f25e6aa1d67a8246328d733e136e612fcbc73a41afd71ddc5a26" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCham-ExtraBold.ttf", + "size": 32332, + "sha256": "1e46329b0ff2bfb27a5012524f8effa2fcf8ba5fcdd6636e1d6a2ac1aec8e62e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCham-ExtraLight.ttf", + "size": 32104, + "sha256": "13af80e6e928b9426068ea65812e496b88be3c4e33bf5eb62578853f06220f43" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCham-Light.ttf", + "size": 31900, + "sha256": "f790ea81beb7fc4fa5606b57b1bd703c57cfab897ebb0833689cdaba3055c5f0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCham-Medium.ttf", + "size": 32188, + "sha256": "f787391fda446e18360a9d63ccd86d931dfe3c74b12305accf062128487a7da0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCham-Regular.ttf", + "size": 32036, + "sha256": "d48a34e02b4096eb3f2e008b74459789540598c2274d65fc53012517d0b08d92" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCham-SemiBold.ttf", + "size": 32304, + "sha256": "7609210d5003850a818d8ce3aeb674c6b26913ca7a85401dc74c74b7f543f2b1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCham-Thin.ttf", + "size": 32008, + "sha256": "1b99cb6e45eb3608f5c26faebb6431f8e045030a030fe2a54f7f979ce926d2d0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCherokee-Black.ttf", + "size": 106256, + "sha256": "5f6b9486df900347094321d17de0c9f8438b86eeb3a680d22aa3173ce451fd2f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCherokee-Bold.ttf", + "size": 108548, + "sha256": "5d60c5479b6e9af2f9fa6ede6ebeba471dd8c391333bcca91c2f9d0903279c6c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCherokee-ExtraBold.ttf", + "size": 109032, + "sha256": "f815a84b26277b99729eae4fcd8ad8cc6131b643345efa2c50a4243e3f6fdbf8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCherokee-ExtraLight.ttf", + "size": 101084, + "sha256": "3308d1f241c0b04bc21cf3aabfe972ff46dc5509903693909d681c9ef5d42376" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCherokee-Light.ttf", + "size": 102516, + "sha256": "4ecdcc52700e2b9058aafd609300fff4999d462023a23483e7fdbb4d531bd8c5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCherokee-Medium.ttf", + "size": 107224, + "sha256": "34a5dbfe18571748a5c7f91e939ab7d50ba16cdda5d1e712c3423ba0b72fe839" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCherokee-Regular.ttf", + "size": 94732, + "sha256": "c052352137ae8d283840a0e2991a675d47859d8fdbae5726d373d4f0d97a8c87" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCherokee-SemiBold.ttf", + "size": 109268, + "sha256": "5d28f84a72aaf25b49ddd758595204e1db24d56ffd12588242b52bd68627d015" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCherokee-Thin.ttf", + "size": 96928, + "sha256": "14b80500a73c20db606b44d43c47d91104315d427f5b47a6ecf7985425a60680" + }, + { + "path": "/usr/share/fonts/noto/NotoSansChorasmian-Regular.ttf", + "size": 17356, + "sha256": "9d5a0971077f541748b88fa93f8a01c21f6089aaf7fe446b6bd9f20e5300edef" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCoptic-Regular.ttf", + "size": 48948, + "sha256": "e70bd535d7e6cdf2346eab36ea76441059b18ee14d3243e85240b5e65eb0ad45" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCuneiform-Regular.ttf", + "size": 819980, + "sha256": "aad6f345a2f3150aeb51706ecf1d6f62eec299ee215cb77e76f0c33e1419bba2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCypriot-Regular.ttf", + "size": 14656, + "sha256": "0b276172afb0624c811c62daf4b8fb6ce3fa018fef94cbae59f4bbbae367988e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansCyproMinoan-Regular.ttf", + "size": 12552, + "sha256": "c21895c2edc7c89dfd02a5298539260bc2174fa6d13acb188cfc76b9f215a7cd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDeseret-Regular.ttf", + "size": 19504, + "sha256": "9f384e8a75a059b8efcbead73ef5aa3b504ac3e9d218be5368a20b19bfccdeec" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-Black.ttf", + "size": 252324, + "sha256": "54e439f1a9366bfe275ef5ccef9c71675c82180b705c4836474265b699e9331d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-Bold.ttf", + "size": 250904, + "sha256": "3ad8362a06271814869838dcc3d161b13c9fb97681b627af1f7f283ea9387d56" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-Condensed.ttf", + "size": 239568, + "sha256": "1f1450d4cdfe856750b9624d559b7603c8f29e8bf3aece5ccc307c6e03f25ab2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-CondensedBlack.ttf", + "size": 249676, + "sha256": "306d4ea3c89112397daf7f31cdd81407835ffbb932bb6ea0788b0820894eaf7b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-CondensedBold.ttf", + "size": 241004, + "sha256": "c8552f5ec255f4772993161d625b37264d38e79ae266cbcf7d8cf1f9c80b2c23" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-CondensedExtraBold.ttf", + "size": 246800, + "sha256": "a3bb1195e0a6df8478e3b3bff55fa22dc1c944e019feee64ae9489d3525972ab" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-CondensedExtraLight.ttf", + "size": 234260, + "sha256": "9803012841269a27807e6122495754998c22723ff184d7cf5f1cab8373e81466" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-CondensedLight.ttf", + "size": 239612, + "sha256": "def73656562d8d45a99fdd4cd1396cc9ece262c8a80347360f20df43aae8e536" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-CondensedMedium.ttf", + "size": 244256, + "sha256": "4a0839d9bc7cfb428d7ffed47420c3dea1cb248ea994fb08142daf3761b3a4eb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-CondensedSemiBold.ttf", + "size": 244688, + "sha256": "98afc7ec21213b23f37fbee69595119a78991bef7262c6ad23b7d5ca4276ad9e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-CondensedThin.ttf", + "size": 234384, + "sha256": "e4ba580d7f07a00acdfac632b4cf3718348da81c09603e33c719ff1e23488dbf" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-ExtraBold.ttf", + "size": 248884, + "sha256": "0ec819d668eaa1240cdde1bbdf594bbef5f78bc36e14c5a096a129f4450c2c3a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-ExtraCondensed.ttf", + "size": 244764, + "sha256": "430ea4af010406e53d11b58c5aac02c2b57700864daa0b29d2597b3648278d96" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-ExtraCondensedBlack.ttf", + "size": 247484, + "sha256": "b632687d58d040a3a27566d0a3fa438c52b4abf2ab82e3370f4a5b5578ba6178" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-ExtraCondensedBold.ttf", + "size": 243480, + "sha256": "afc6466c2c283b750a66be908adc1833a8e83863b2792f6efd59a9ab334559d6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-ExtraCondensedExtraBold.ttf", + "size": 247144, + "sha256": "9e8133265d5a4981f7d73db628925798ed4cd927e8a2a3015abce0d48efda876" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-ExtraCondensedExtraLight.ttf", + "size": 234716, + "sha256": "d8354d06f319c6f23a339a77bae9641fec81c83d6fa47692dc3dcfb5d1086fe0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-ExtraCondensedLight.ttf", + "size": 235160, + "sha256": "0d985e861a5ce06c92f15e13a28a3d5fa1addf2e6bb5fc8abb1ff1a382ba7f51" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-ExtraCondensedMedium.ttf", + "size": 244112, + "sha256": "e6e77220f5e88f422a9b6935155fa0b6094cf22585c60d3e257b7078acd81df1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-ExtraCondensedSemiBold.ttf", + "size": 245964, + "sha256": "b87fd6cb5178b2eb710e56f40829b250d03b8d56c887ecdbcb54f1ea0bf9535c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-ExtraCondensedThin.ttf", + "size": 234648, + "sha256": "c0bbd80676f3e6012c31622061799d54ad5baad2d95d1d50e0efaf943765782a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-ExtraLight.ttf", + "size": 239936, + "sha256": "2a301475cfc276996e9915cb098eda5b9daafa7cf393f4d3226c397ff486faec" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-Light.ttf", + "size": 237168, + "sha256": "a4e3815013f832c118342592dc50d5a471cf19cb2e67fc0f038bfac013175174" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-Medium.ttf", + "size": 244504, + "sha256": "4f46cf50b7992e61c7cee7c4a8284450cceed8b540b8adbc8df637c982c65757" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-Regular.ttf", + "size": 244284, + "sha256": "306b53ecfb182a504dd8a7446093c316387d2fd8dc350d0792ed1753fe0996cd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-SemiBold.ttf", + "size": 246748, + "sha256": "9e98c0962a3bc2e60f49c544c37f25a0241e43683a9b459d66c25fae560aa5dd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-SemiCondensed.ttf", + "size": 240348, + "sha256": "c622329e26187a0a3235ae5c372d6519876d47cf2169a43ac76289fc33be7744" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-SemiCondensedBlack.ttf", + "size": 248592, + "sha256": "90c34bdb2ba99340344bf7cba1160d042df01f97592ab9c56bad427406d979ba" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-SemiCondensedBold.ttf", + "size": 252144, + "sha256": "cce939220b2d3b6d9239c36c8e864b7e40f673c73f044eb1a0278d8d717fd1a1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-SemiCondensedExtraBold.ttf", + "size": 249648, + "sha256": "b12f5ac0cdecd25691bb16dbf5ec631749036b18e933a0bf5d7b23e7ed5d4a31" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-SemiCondensedExtraLight.ttf", + "size": 236284, + "sha256": "abe7ccdda668b345fa8bfbc36f385a86ec7fbd20b5d1d0bed8edc3592a1ceb0a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-SemiCondensedLight.ttf", + "size": 237200, + "sha256": "b28256cafec67c37a64840443e468be6b9b9ca7350f7f03b13ab6b7785fa1518" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-SemiCondensedMedium.ttf", + "size": 243356, + "sha256": "cea75befdf049534b9a40be3ce018e583b62b0c3d37a8cfcd06417aa8d810e82" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-SemiCondensedSemiBold.ttf", + "size": 244564, + "sha256": "cf0cfad7c1ab64d8dac642f3a9207e9bdfd15f033fb6bb9e283cfd0819009736" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-SemiCondensedThin.ttf", + "size": 235360, + "sha256": "a2f698c6cba2f534ccb45d55a5444540893c0baa2ae2c422d41ab2119c6d71dd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagari-Thin.ttf", + "size": 236832, + "sha256": "7facdd4993e9616fb8aa70a9b305936518663f6ee841d53c7e23cf74e6dac19e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-Black.ttf", + "size": 246020, + "sha256": "6524ade3cc492b4b5aad040a6a84164ac72f4de7cdb360358c7b680f2a62fd68" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-Bold.ttf", + "size": 240840, + "sha256": "4c0e2fd89ba7af5ee31844dc1bd652aa6dc389819bc52f31a635164090c7568b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-Condensed.ttf", + "size": 234208, + "sha256": "e31b0f9aeee64521e3d3c731ca11a132509f633c6b8a8bbdb14397d93723b592" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-CondensedBlack.ttf", + "size": 247180, + "sha256": "7aa6b84312c2bc7a43ff1222297d5c793a7a1e49a22bf4c87ba08a53db39ff17" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-CondensedBold.ttf", + "size": 236976, + "sha256": "246c832fa06c708a799339f01f2ac2cb014fee53f147b03e58621f09b2ecdc77" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-CondensedExtraBold.ttf", + "size": 241440, + "sha256": "c27b4ea3b38d4d3f8289d1c0592072e33de0f12bef2594ec2e7fe9c086360e4f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-CondensedExtraLight.ttf", + "size": 227880, + "sha256": "8565d9da0a84ecefb5d7186f8c2200313fbb723372d5efcfe75c54e373d9d9fa" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-CondensedLight.ttf", + "size": 228052, + "sha256": "e52ba2f6bad90459fc40c6dbf5c8cec5c3daf63ac3c3e1bf623e99c6c0375498" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-CondensedMedium.ttf", + "size": 235876, + "sha256": "866a6df37c54fa8504894b6c412122a49587c0fc81e2df59135bfbd315fc5d8a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-CondensedSemiBold.ttf", + "size": 235864, + "sha256": "d52cf751f2dd2c61b84bd5f238c2838d6614f51023a6c1a2b8d57cf87c184998" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-CondensedThin.ttf", + "size": 228332, + "sha256": "2ceab13ceb86b3cd9c4ee9f64a795c5ca452564759a8f4730c0bc18e8821face" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-ExtraBold.ttf", + "size": 243516, + "sha256": "814e8beb468303a5b3effd3b5b75906e4963db0bdfbbcec2f489229e08508cd9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-ExtraCondensed.ttf", + "size": 234864, + "sha256": "b1ea1c8c5917b2ac801b92483293f996da0d965268af7c1e7ddfb90c13081171" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-ExtraCondensedBlack.ttf", + "size": 242076, + "sha256": "da028b80a15622acc2a8cbc08b0c47ec3785b3fd92ba81fcc7640bd887527938" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-ExtraCondensedBold.ttf", + "size": 236668, + "sha256": "71aa3c235e8ec28d989135b2f8b2eba2d0899a6e58cd391db7bb6a5b86526733" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-ExtraCondensedExtraBold.ttf", + "size": 238428, + "sha256": "f1bb432c4b177d45bd3cbed138540e5743c35a2f931a90686769db120ec8f189" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-ExtraCondensedExtraLight.ttf", + "size": 226784, + "sha256": "15072f37d08c0a83606575851887ed7cea47e8def367dc8f37b9b19981001b24" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-ExtraCondensedLight.ttf", + "size": 228016, + "sha256": "b699886b5dd6dcfd6fdf67e12c8d11f620be92d402c53468b15eaf58c33cce4c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-ExtraCondensedMedium.ttf", + "size": 235204, + "sha256": "7ab22c1ae89b29eebdb6982eeff2d16cc4048df5509c831d8a39e3bfc2414b6f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-ExtraCondensedSemiBold.ttf", + "size": 234792, + "sha256": "9596e8311ad98511b2d878031eb80062a519094cdedce76b69c16bb3b67122a6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-ExtraCondensedThin.ttf", + "size": 227872, + "sha256": "6258d51c9e115d1b9106d63238b97c07f462e368d682c0411c42e589d583b54e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-ExtraLight.ttf", + "size": 232576, + "sha256": "6cb8af57ba0353d7ae4fbd98df4a6d6aff757e6be5c787b3ab3d1e2a754ce49b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-Light.ttf", + "size": 230592, + "sha256": "a74d7314ed89f3210ea4aca73b7530c0c59c049a244fd89abe6d442a67045f19" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-Medium.ttf", + "size": 239100, + "sha256": "5fc1e5a38a8b938404f373335dedf034037f3519f5e99d3fa81ec3bf89b9f765" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-Regular.ttf", + "size": 234516, + "sha256": "43c9218d3b4fc2c19a40ed83b374dab9b212115e8daadd3a75bd3bec965bab6b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-SemiBold.ttf", + "size": 239112, + "sha256": "be1cca07c862ee7cbdf1081f34480cd42b0a4028d9f456eeca50a4783a4b414f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-SemiCondensed.ttf", + "size": 235180, + "sha256": "7b11db68594c4f203614174851268a73eefe7c859411b08aed0c7f0919ce7a45" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-SemiCondensedBlack.ttf", + "size": 243120, + "sha256": "ab4d9a0577a569468fee69f6990ab007918bceadda51931beb4174e18d172a57" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-SemiCondensedBold.ttf", + "size": 239192, + "sha256": "3126b102fb17af09ecddcbb3ec7c225f48d43a23bb7b3857d227863f1e3bb292" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-SemiCondensedExtraBold.ttf", + "size": 242620, + "sha256": "0f640e51487d3418c14d7a4e69f5a1e90931c0f23409349cd6f0a73fe6eb880c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-SemiCondensedExtraLight.ttf", + "size": 229756, + "sha256": "bdbd559002537e21fa114c47b098b8788a04b58bdcce8968422759d43c5d8ff7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-SemiCondensedLight.ttf", + "size": 228992, + "sha256": "83cb42a2304d35577f4c39a63c89d76b97030d1f13687de6b404f2e0d76c342c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-SemiCondensedMedium.ttf", + "size": 237096, + "sha256": "2ecded344e6109e8405636ee991042fe9173a858fe3cf5cf93625b9289f7c1c1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-SemiCondensedSemiBold.ttf", + "size": 237816, + "sha256": "7c028924bdf0c0d93e87392b7a48e430a2d644f0a1c41865e61a2ce1a67de164" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-SemiCondensedThin.ttf", + "size": 229812, + "sha256": "fc448139dded44c59adbbdeb1dd16965e42b896919c4ff369cb710de8dab34d8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDevanagariUI-Thin.ttf", + "size": 232964, + "sha256": "71a20af9026a6df29f29ca043ad309cc6f7c5b6fd72f0d9ee1ffde45add1e93e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDuployan-Bold.ttf", + "size": 1570792, + "sha256": "8401008ef67466012030c53a1cbd30bf29a6d5ae2ec90c6531801bdd66719f1b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansDuployan-Regular.ttf", + "size": 1553168, + "sha256": "de5ee49ff75acd2195bdb3296577d7527636520a245e9392a40bdb7ee86cdd7b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEgyptianHieroglyphs-Regular.ttf", + "size": 585872, + "sha256": "38a33a230624671eebedce95bd4237f7b3b2bb1fa25688ff959bed4070a1ea95" + }, + { + "path": "/usr/share/fonts/noto/NotoSansElbasan-Regular.ttf", + "size": 18888, + "sha256": "6d52707e91a77e23f389f42b5da65d7047205e7833041fe0b2cd7ff280e14749" + }, + { + "path": "/usr/share/fonts/noto/NotoSansElymaic-Regular.ttf", + "size": 8884, + "sha256": "29571749fede39715b09cbadbfedcac089acf33475137a0aac7addd91d333181" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-Black.ttf", + "size": 374056, + "sha256": "0bc33e28e4139799dc025eb6bd2dfa8d60116cd2eea885ec7cc1945e56e95de3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-Bold.ttf", + "size": 375172, + "sha256": "b45f16db963a94ccb6590b65d3b0e48f55cde011f7c0d4f3c00531e6b8500d6f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-Condensed.ttf", + "size": 386256, + "sha256": "0d53545ebd8244e7b17fd04594142f55ff1e093d954af0be4857a7d5812c08cd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-CondensedBlack.ttf", + "size": 392356, + "sha256": "ecabac5b4de1dad73fc45f20baf00a4cb8134766f99fc799bf2bf51e8e1d8ed1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-CondensedBold.ttf", + "size": 388780, + "sha256": "22bb4295e035b12f1851c8fa35aabbce6b9b356dc1197a3957d7e845fae9771a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-CondensedExtraBold.ttf", + "size": 391888, + "sha256": "46cde7ec464f2ec849b470cabc5df58ec833971bf74af60092502d5fae88f88c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-CondensedExtraLight.ttf", + "size": 393104, + "sha256": "7e424ea1e62776a20d58e02cc0bf71ee31d570d2db3f22b05baf3130dc0f46b9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-CondensedLight.ttf", + "size": 388532, + "sha256": "3a60fe7d70f1e89a1d8d8cad8d2fa0e83f97d7f4a673218f25d7273b81c2ce14" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-CondensedMedium.ttf", + "size": 393724, + "sha256": "44ed4d7a106e35f534b72ff73a437d49a0d7431cbfb74385c15e3682985d7861" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-CondensedSemiBold.ttf", + "size": 390384, + "sha256": "c0fe5d22b7325486a1d907084fe880631c2ae63e913ab19f3fcfd1da2b001167" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-CondensedThin.ttf", + "size": 356076, + "sha256": "4009f18c538ca02177a50953c0f4bbe98601657d85c4361dc619a7a8b01fcc17" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-ExtraBold.ttf", + "size": 376972, + "sha256": "5540da0ea6356823168fc5453bd633fe2874c8d1ba3b14dd4300d075715d7559" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-ExtraCondensed.ttf", + "size": 324108, + "sha256": "5bcc82347c23ea2f5fc412e34fd5c04eb9b5d762ac74ea69356196f5051dd87a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-ExtraCondensedBlack.ttf", + "size": 322236, + "sha256": "3e5e9c41936344897d0bf8f76b8662720858374a798a2967854abd7e48f4221a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-ExtraCondensedBold.ttf", + "size": 324648, + "sha256": "d7f87e46f666ea966f6dbfd849503ff18d9d5e9bb37ed84ad818129c8a8bd2fd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-ExtraCondensedExtraBold.ttf", + "size": 338600, + "sha256": "b2ab84dd9dd8bef888407eafea207b928930d4a4b4ce1f3a5c2c01d2879600bb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-ExtraCondensedExtraLight.ttf", + "size": 333136, + "sha256": "6e24b206651ee2bd16ff635ee97b6575d3a6a294cc86bdf20859a850ebeef524" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-ExtraCondensedLight.ttf", + "size": 354072, + "sha256": "bdc5332f29b94edb9583516305ccd40c1fa0fa37258898117f1cc10bd4b06d9d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-ExtraCondensedMedium.ttf", + "size": 342676, + "sha256": "4fe91c548db1283abd13e6292cf1c97388930d5608546b1d7019f880a2c80ec9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-ExtraCondensedSemiBold.ttf", + "size": 337508, + "sha256": "58371a2215f056a0e509d62a9e40f1f82bd4796796caf952d46b60180ce0022b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-ExtraCondensedThin.ttf", + "size": 302556, + "sha256": "3c4306154fd88bf5001df1b2b7f8e8aa7d697b99006d2762df28f75cc601a9f6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-ExtraLight.ttf", + "size": 369444, + "sha256": "f449ecbb5282425dde9e2f40cf9a1c265fa87b1dda335cde33cb1c679ae5521a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-Light.ttf", + "size": 370940, + "sha256": "32f573965fe3bb68ae6c7f594501afadeb5aee0ecc4e140b938f459c81a23732" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-Medium.ttf", + "size": 379976, + "sha256": "d5c09c921049aa49066524e68cca4d7a5f5d07c30a0f1f436902910bbb8dd60d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-Regular.ttf", + "size": 376804, + "sha256": "f6f7fc379db9438959a2b0527e7a2cf36ea9c84626d56ec444fff37fc24c3c10" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-SemiBold.ttf", + "size": 374200, + "sha256": "c4447908a95c2a7d8721f047fa19e95700bdf09d1b15e185be922263d3aa72c2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-SemiCondensed.ttf", + "size": 391776, + "sha256": "c3dc4e19a95fcdc2d319efb79d674241c9d474cef7b97bcd489497e67bdfe5b0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-SemiCondensedBlack.ttf", + "size": 387520, + "sha256": "641b1752e33bb3a242a223b47de23e645e58c6a12b3b37f868e9c886675deca1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-SemiCondensedBold.ttf", + "size": 390972, + "sha256": "76c24fff4ddec77e686e39e40cc73bafcd1680b405c3b4c094600040e40d4b4f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-SemiCondensedExtraBold.ttf", + "size": 392848, + "sha256": "a2d776979fc40f78939c30a3f5913c5eb5b2e4072e1907fe6b5581f44c292dd5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-SemiCondensedExtraLight.ttf", + "size": 385000, + "sha256": "60c775836ad930491e599f087935431d355b0ac40fc3869a1bf1d51ae24abeaf" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-SemiCondensedLight.ttf", + "size": 389424, + "sha256": "8a6b7a30fa35a9667624270709ab136a933a9910002295f3e7796831876f4982" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-SemiCondensedMedium.ttf", + "size": 397516, + "sha256": "af6ab84c9d79f782f23ae160e448eef7b8abd378c57abe21874c60b80f3c4fd2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-SemiCondensedSemiBold.ttf", + "size": 390844, + "sha256": "061c46f8d703289313d637a873b778168cb51a445edda0378c87496fb009e149" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-SemiCondensedThin.ttf", + "size": 367680, + "sha256": "4952664bc53bd3c5001980b847b26ba444bf0719054e56de056b0f8505c66b3a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansEthiopic-Thin.ttf", + "size": 336968, + "sha256": "102e84de41f56ae0a907d2eabe7bc968036a4758280d0963c03a3be3ef26fc54" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-Black.ttf", + "size": 56820, + "sha256": "71221e8385c1caf626fb6d005c729b85bbb25fdc47494a5235acbe900a56fb7c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-Bold.ttf", + "size": 54748, + "sha256": "a7f6bc365b7c33372e9ff0b0422f35dad52df501a8fcd4e8766cefa430508693" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-Condensed.ttf", + "size": 53044, + "sha256": "2f6d49cbfe11e8ec7cdcf27035c9c795f685c6919f929c9e1129c956908e1f00" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-CondensedBlack.ttf", + "size": 56716, + "sha256": "dff32f134b6868a59ead686bbdede8c7e608c2b5ea2a91693c6019e67de489be" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-CondensedBold.ttf", + "size": 55172, + "sha256": "48eee79cf59f9595be92006d5057f8b46b7672f5fd45ca838b6098165fd51612" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-CondensedExtraBold.ttf", + "size": 55564, + "sha256": "d13267dae54c910ef4e05be1a7f55566f075a59a24d5ce94169b609180006190" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-CondensedExtraLight.ttf", + "size": 52592, + "sha256": "0642c9242b22645032fb9398882280c6d8b400e1b21d896a676bc743553ebf15" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-CondensedLight.ttf", + "size": 52128, + "sha256": "75921e2410a2c575db601de29d45813e6c2e16167515e0cc2aac588c440f4275" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-CondensedMedium.ttf", + "size": 54324, + "sha256": "70bf2b96b080d0b0e9309b6c382cfbc8d322bde8e96935247da7b31c1ee35e71" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-CondensedSemiBold.ttf", + "size": 54640, + "sha256": "297e9c4bedd2e43858544fa96c9e12a4794704c1032c37f55766cf499049b350" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-CondensedThin.ttf", + "size": 52556, + "sha256": "cd52355d87b3eb7abb2ab64f8040b51b06e5a26324f4b5761207513a04e6847f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-ExtraBold.ttf", + "size": 55548, + "sha256": "521daa595e1c740d6b0d6a754924c13606f3bf7d9a06585c3b1c06af6a29701c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-ExtraCondensed.ttf", + "size": 52924, + "sha256": "7e55c3cdb5936341e3ea36df690c18d5a1ec185018d49a94733611391287be1d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-ExtraCondensedBlack.ttf", + "size": 55792, + "sha256": "73baabb0b8a2cfedefc0e334c314ed868d1de9c516963e771eb1eeac467e332d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-ExtraCondensedBold.ttf", + "size": 54756, + "sha256": "39605311581d614aa58238c0d2d595dd7d0abdbf99b6bf69094f452813732131" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-ExtraCondensedExtraBold.ttf", + "size": 55240, + "sha256": "b2464278462605861e2fc539409e43a97d183d985f247d41e65dc456105093f0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-ExtraCondensedExtraLight.ttf", + "size": 52188, + "sha256": "373a5014ce6ec27018bc667aa197fc3449281557c2400b3977124484a426216b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-ExtraCondensedLight.ttf", + "size": 51900, + "sha256": "ef6e5009fc4393370d84462302271301ba72eb9dbebe552a1f519775789a2dd8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-ExtraCondensedMedium.ttf", + "size": 53616, + "sha256": "c95d809dd0ecc3385eb3c41018997ed577478a9ed4fee55fed997eeab8c7d4e8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-ExtraCondensedSemiBold.ttf", + "size": 53988, + "sha256": "5b7cd4fbea39f9c19a904d1be00fa82d2959098cef51ad8c2123acfe59cbef1b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-ExtraCondensedThin.ttf", + "size": 52120, + "sha256": "3343e26464ffafee07600e4fb6381dfff532906613affef9ae73b5cdcf6ef642" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-ExtraLight.ttf", + "size": 52776, + "sha256": "5cf0f79bcc2a95705fdb3d6dc3333920016fdf67384406135b1c3b0d52abeb38" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-Light.ttf", + "size": 52420, + "sha256": "b0a7f9715b34aaec180eced295f1063b705c5925194d73460e76bf940ce48cfa" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-Medium.ttf", + "size": 53640, + "sha256": "92f16c581a8b3315b55275687506a015caee6adc5fe6945168556f68ce163523" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-Regular.ttf", + "size": 52888, + "sha256": "d3e33254b09e7bb2c5cf0f17e554b80462056c5a107097f258d495168c3a9346" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-SemiBold.ttf", + "size": 54388, + "sha256": "c05e488e23da5f5125767e5376b911666b895379c604f0ca12a666091f4ccedc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-SemiCondensed.ttf", + "size": 52948, + "sha256": "1839a1ca8eb5dc8a6bf750d061791caec8eb0cdbe483153ce631f7acc1393847" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-SemiCondensedBlack.ttf", + "size": 57276, + "sha256": "92ba5049baf7d7645448256594b0d034322b349597de00e38a6f3b705a1047cd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-SemiCondensedBold.ttf", + "size": 55196, + "sha256": "cc43fab852550cef0e69c1078a33ef31f42092748e0094f2e6f8772ad27fb434" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-SemiCondensedExtraBold.ttf", + "size": 55936, + "sha256": "f4fb5d93d4f755cc417560b31fae2c6b2fda74b0635ec667fe9d83ec30d659bc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-SemiCondensedExtraLight.ttf", + "size": 52660, + "sha256": "9bd4be145ee3b6d55da91f0daa24625450c8ebdb686f5d7bb174392ec9da1913" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-SemiCondensedLight.ttf", + "size": 52292, + "sha256": "68dd92baa242a86056130d368e012e774b2a4d1ba987c8e812484e1134d31f5e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-SemiCondensedMedium.ttf", + "size": 54384, + "sha256": "e86c01b0d5d82964b2a18a4b95b81ff9fbb32102a218d6d1936e576a68f50de9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-SemiCondensedSemiBold.ttf", + "size": 54828, + "sha256": "888d7662e4ececf6d6ecd729c8759d2e166b8b41db2a3ab97a1dba2a6ba80087" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-SemiCondensedThin.ttf", + "size": 52628, + "sha256": "f4349e3a7e6389cf05fb836d57b3b04e17a6c95d0323a2ed6c786fac2dba2a4d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGeorgian-Thin.ttf", + "size": 52752, + "sha256": "6ad06d2780c05c161c456246c8089519a2db633b8bb08ced450dfcca301e25d8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGlagolitic-Regular.ttf", + "size": 40812, + "sha256": "bb00b6d9cacbab378ef11b1f83250495cd7b6503d143c75d90b1efafa76c691e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGothic-Regular.ttf", + "size": 11824, + "sha256": "05edd26e173acd43fdf4cfdad4d051ba70a8d4de76042e9f15bd793c037f8239" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGrantha-Regular.ttf", + "size": 364160, + "sha256": "41dd39f2f16e9539751c732f8d276079b98277bec2ea5945232a7ac594198000" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-Black.ttf", + "size": 205948, + "sha256": "e8dabc1c8769db07c89a36c0c19b53e8aff042056adc09aa305888b780b1dd7d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-Bold.ttf", + "size": 201032, + "sha256": "7c1da9cad53f83804a330bad413b015987f4b6520da54263b0e39b7b8736525b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-Condensed.ttf", + "size": 189940, + "sha256": "4ea1fd18dac1c9fb7da6452f8066bcb8d4a430173223009eddffcefb98b5882a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-CondensedBlack.ttf", + "size": 202648, + "sha256": "bfa322717c6f89a8b251f8c05b1233ae332359deba0b5fee135c2679535e1784" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-CondensedBold.ttf", + "size": 200768, + "sha256": "f0eb59069c4855431dd740755220eda59efa0f63ede428369ef2629eac844632" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-CondensedExtraBold.ttf", + "size": 206320, + "sha256": "6e7a94e273abf30f9c52152f3e503320d0e75c994ff83952ba62b99b1074368e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-CondensedExtraLight.ttf", + "size": 192272, + "sha256": "62602a0778e93ce9d98db118dde45ea8381a20f931bd5644094ab8c49e8b7082" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-CondensedLight.ttf", + "size": 189352, + "sha256": "657669293bb874adafb52aea648707276933c40988140b0217165ef543f44422" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-CondensedMedium.ttf", + "size": 195644, + "sha256": "64a964a02dad4511c21c50f64ccc21abc1417f9007339cc107f35b285b6cfcf3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-CondensedSemiBold.ttf", + "size": 196848, + "sha256": "f58c1d264497776ad8362c6ca47dabef26ee553f9fa9c41aee0608026bc577bd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-CondensedThin.ttf", + "size": 192308, + "sha256": "141b9c7979e2d812e3927a18fce53b8b5dbc45eec989dbc10908332e6ca1c4d9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-ExtraBold.ttf", + "size": 201080, + "sha256": "3cf4d15ad102e8373c02986f4ce425a0c71c764f0394a8e39f80fcb1de1c46b3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-ExtraCondensed.ttf", + "size": 188764, + "sha256": "e01805d19fdb2116dbe7125f5066abd98cc8cd9ac7fa5f49b663f6c30f82cd08" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-ExtraCondensedBlack.ttf", + "size": 202296, + "sha256": "1b6947dcf77ba944f3372099fb755860409d07919378141d67c77632107b053d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-ExtraCondensedBold.ttf", + "size": 199728, + "sha256": "a9c9ec698a60a3fe58cae1bdbab49aaadba5c3e1b464ad3632ba85473ad391d9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-ExtraCondensedExtraBold.ttf", + "size": 199180, + "sha256": "4cc2229c0016addb80d3aebf5b9b09d121d4d9d2659892fa05dddb1516c1f2b9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-ExtraCondensedExtraLight.ttf", + "size": 192496, + "sha256": "a67d1e86f7c73e6ccbac0b4a4a59922d64f9480dc69fe4246916977b5affc411" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-ExtraCondensedLight.ttf", + "size": 188028, + "sha256": "e42a1fa453d7b481e50c355f4dbca331f1a2cd1de7fc182ad2d3d6ee6ba58c9b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-ExtraCondensedMedium.ttf", + "size": 195356, + "sha256": "fa971026fe522922dceabb04170a3c1180d155e3e165ab4dca3f9d4007ce74a4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-ExtraCondensedSemiBold.ttf", + "size": 196672, + "sha256": "a0c4c7d33d117b91201e1db89f052e31e58de5ad29aac875a01336d8a90ccad3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-ExtraCondensedThin.ttf", + "size": 191840, + "sha256": "cd6207baffc269b0c4625cd8f778ff98c941b6f48a38246fbf52a434bc853474" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-ExtraLight.ttf", + "size": 194716, + "sha256": "b8f72936349070fd3e6712a69cd8b06d196b4958027b6b099521d373db01c759" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-Light.ttf", + "size": 189768, + "sha256": "dea8077dba623d89a71ebd8f018d233298bae400bbdf01bd9227f404a93921a1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-Medium.ttf", + "size": 201304, + "sha256": "28b9986004e60d073562ab1be7de208b6c890a48cbd1b661c322fff70817d025" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-Regular.ttf", + "size": 200704, + "sha256": "9b5a7aaeeb649a2e75a49d8b006a1f87db1b61c0df3b001609f4e0725d88dbf6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-SemiBold.ttf", + "size": 199728, + "sha256": "aff71b4d505fe2cabcc69489960cdc1559745956c72fdc797b1941c43747c6f3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-SemiCondensed.ttf", + "size": 196432, + "sha256": "c7683a3eb737561dd42aa11c7e108298e6194fa99b9e6d864e7d670f105c499d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-SemiCondensedBlack.ttf", + "size": 204056, + "sha256": "db19acfc012aa0e3f4cf8ed57bdddd1f5d1731f5fdf4a33e926d69a0a0617013" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-SemiCondensedBold.ttf", + "size": 201772, + "sha256": "54bb2d404bedfc6f75031008ea59b6ed9bf353372707c9428dd1530cfa28c94e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-SemiCondensedExtraBold.ttf", + "size": 206768, + "sha256": "e8af4f38d18a19ce757a90f9442e646d0c0188893677b3c9d0282daeca9524f8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-SemiCondensedExtraLight.ttf", + "size": 195000, + "sha256": "023ec17aa9906a096b90477a57ae55f94920bf0881d990053019eb654c81c7d6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-SemiCondensedLight.ttf", + "size": 187728, + "sha256": "5c9618aecd168d7dd19c837a55401f90c87757eea448d661f9e96264dc397c82" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-SemiCondensedMedium.ttf", + "size": 196856, + "sha256": "f8f9f0cab8e4e366d7b2773d888115b6b87ee0730795d10c6ba25199198b5f0d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-SemiCondensedSemiBold.ttf", + "size": 199116, + "sha256": "1f66b13447b5ad0cf201c47d8519694ee3a974c511696e99ad062dacc2389b24" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-SemiCondensedThin.ttf", + "size": 194324, + "sha256": "c029b68c0ae7c646168aaa8306caafc8fe934a8030b165125e459a26e2b7d0ea" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujarati-Thin.ttf", + "size": 195680, + "sha256": "feb2aec0a31606fb8a3842ff2c082662254c433559f2683b88a03a54d88af236" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-Black.ttf", + "size": 205952, + "sha256": "139992a8b90e0d1284566d0a9eb2a9c67e827b1ff1e52abdc6bbe7341172bd77" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-Bold.ttf", + "size": 201072, + "sha256": "377a8690e4d91ee22af114ffa0173b27e6f420e5bcebdcc57ad1cad81869fea2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-Condensed.ttf", + "size": 185908, + "sha256": "d6cd6c683adaaef5f16c1e57851ffdf94ceb14159cfc9ea90857ffb87279534b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-CondensedBlack.ttf", + "size": 202624, + "sha256": "2229c925215d9f2c2acf6bf294dfe8564db58a87445bfc509fe7a2f2f840c944" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-CondensedBold.ttf", + "size": 200816, + "sha256": "4eacbf449a0d1ad9dcbe2f6f2af78e184ff411948dff6adc80c190878ec13c8c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-CondensedExtraBold.ttf", + "size": 206644, + "sha256": "8501cd5f4a67472db2a4d86f95675fdf5f069844edc19ee7a4f505efbe971571" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-CondensedExtraLight.ttf", + "size": 192316, + "sha256": "fa49ed3d566bf3489c0602ac691f452039c4fb384f346977b81b2846b1ab86f4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-CondensedLight.ttf", + "size": 189400, + "sha256": "11995e1f065546924399be4973f7038c29c40ce32851aca8bcc82a394f8fdee7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-CondensedMedium.ttf", + "size": 195592, + "sha256": "2783cae8784dfc592236f3377a95355d219b435badfd48a1c39b0eef006eabc3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-CondensedSemiBold.ttf", + "size": 196956, + "sha256": "dd7b229a54851bc02f0a4e9ac0af2ddabfed20f31556471b9de9c96a451eec1c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-CondensedThin.ttf", + "size": 192352, + "sha256": "c98dd630a1579abaeff5a551e1ed68c664d0975017aee4dededb7501bc0d0f23" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-ExtraBold.ttf", + "size": 201268, + "sha256": "6d1fa369ad47501b2081b61d39b3451cde01eaa490d9cadfe5314a2e30dac9a2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-ExtraCondensed.ttf", + "size": 188868, + "sha256": "40d7df771db934a34161f08a2eda17a89cdff7554c62f638c591460c38b6c878" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-ExtraCondensedBlack.ttf", + "size": 202448, + "sha256": "865069b8b1febfcf7beac121496efef55e76326c80a376be373ef8b8060af384" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-ExtraCondensedBold.ttf", + "size": 199784, + "sha256": "5b5fb48ea6dd15ee2f77ace2c6fbb8fc5fc8481b628a88dd5f756d107af09e0f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-ExtraCondensedExtraBold.ttf", + "size": 199440, + "sha256": "01710457441221171b0a6b96e3da6414eb58f2e7defdbb7327f96612b03725bb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-ExtraCondensedExtraLight.ttf", + "size": 192544, + "sha256": "9ad432b709e107976a5c283cb5efa919b503f151ee277d4cd70e4605cd164c76" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-ExtraCondensedLight.ttf", + "size": 188072, + "sha256": "f928c98218866f7ea534c1709db00a1e575968dee3c9dd63df00b149c0cc128a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-ExtraCondensedMedium.ttf", + "size": 195308, + "sha256": "76c9d51aba7bfad185045ee9da0228d05ab811e431dab6b7e7119da322d64fa9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-ExtraCondensedSemiBold.ttf", + "size": 196784, + "sha256": "b4e872ade8940a63d2eceab2acd4ccb4cf6e66fec635c651453cbf0168638f2e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-ExtraCondensedThin.ttf", + "size": 191888, + "sha256": "ed2e372447ea387110887e495936a8433a749ce2c9e2ba1ed3a72d7d2370e727" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-ExtraLight.ttf", + "size": 194760, + "sha256": "212caff218e6a645597d11290b8db43c7e7e26b15a3507a951f17d72615977de" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-Light.ttf", + "size": 189816, + "sha256": "54220ed3bedf79f252d8832199e16866b08ac53c4ad8809664d40247f3ce7209" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-Medium.ttf", + "size": 201348, + "sha256": "1dd374629ee894d6ed615865194c516a76fb2d50b264f20b2c94932dae35e067" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-Regular.ttf", + "size": 200744, + "sha256": "ea8cc768e1f20f52c518f55df0f04e2f41fdf214d6142fdf21496422a1bd18de" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-SemiBold.ttf", + "size": 199788, + "sha256": "2449916a4d53bf35525b7cf8c33f0e6ea1c0802e8555e6cce5f0b898bdd2a081" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-SemiCondensed.ttf", + "size": 196480, + "sha256": "ff870a98bef69198fb3e14e3b3147b438c9456e8ca06e26faaf216a712c6ea25" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-SemiCondensedBlack.ttf", + "size": 204204, + "sha256": "43f505ef959e352319b4005f7d9332ad5d354388a5dfee0ce78cfb229709d304" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-SemiCondensedBold.ttf", + "size": 201928, + "sha256": "d206da3df9bf4c9d287c6fa8d47bffc5967cb6388d121ac2021ca395ffea83db" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-SemiCondensedExtraBold.ttf", + "size": 206740, + "sha256": "1c0f9600bb536be75d3afb680f805281dabbdb72be1aad8bcb560d57359741c8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-SemiCondensedExtraLight.ttf", + "size": 195044, + "sha256": "89c8cb62f4276a8e3a733a0e860f3c873e547adb1fec62343f306afc07f9f9ba" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-SemiCondensedLight.ttf", + "size": 187776, + "sha256": "b0b4f7d139d3f6a7c2ea31097356246f6ff1b0385d0989c4e1d61f690551c28c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-SemiCondensedMedium.ttf", + "size": 196900, + "sha256": "d5947240720f965e4b5eb086a650355a3896cee167c7f75b0a1157060bc65cef" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-SemiCondensedSemiBold.ttf", + "size": 199228, + "sha256": "e1a7f3ce33de8117a3210904e56a588d5f1ecfa79c3f304d314c7a9803597bbd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-SemiCondensedThin.ttf", + "size": 194368, + "sha256": "53e36e60d505faebf1689cfaefd779c9870d17a61e5711aa8f0bd5bf2cf7ec14" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGujaratiUI-Thin.ttf", + "size": 195724, + "sha256": "556ad327f423e6e4cdab5e32c152cd1c6c269b1504f5895fbe4601510aa3023e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGunjalaGondi-Bold.ttf", + "size": 70396, + "sha256": "db72d554dcd2f8fea3b5c0f50cbe8110774938b96dc37d8f0492422f04ed79cb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGunjalaGondi-Medium.ttf", + "size": 69324, + "sha256": "bd03442fae28bc511199be5b42c1943545abddc1708383b3fc317af5fc05d0f6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGunjalaGondi-Regular.ttf", + "size": 69884, + "sha256": "bdc8ed1739118d7c1be43cb5b435817fb7a5ae0acb32c89b2ddd66e7e9c2d1b3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGunjalaGondi-Semibold.ttf", + "size": 69432, + "sha256": "dbb9cd652354713cbf2919ca73ce31906c478672240271340224ff02030e3213" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-Black.ttf", + "size": 56876, + "sha256": "d0773b1f193925f33da6c1e02aa772563be53713972eab27feb326ee33e7978a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-Bold.ttf", + "size": 55088, + "sha256": "4f1bb4b1a05a0ebe1520678132a9448fce8f1741552c5292d7df7a7786d2c956" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-Condensed.ttf", + "size": 54152, + "sha256": "fa35f95ef05d102f41b2f9f0ce6f9a7ec09ee2ac9a55f382588fa9b882b03ff5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-CondensedBlack.ttf", + "size": 55460, + "sha256": "93bc24cba3ca337936938159a9812f069a329f567f07fcaa53db191c07ca33c8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-CondensedBold.ttf", + "size": 54704, + "sha256": "6bf51f39a4df780718925468b79ddc3dc9409cf272a50da30f499ddc82daa695" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-CondensedExtraBold.ttf", + "size": 55116, + "sha256": "0432883d9dfec9fdbb85ab98799e3a5868d5029b05bc4529445f12e8579f6a54" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-CondensedExtraLight.ttf", + "size": 55116, + "sha256": "19616c5205984701c002baf3841b749bebe4e683ed8f1312b77dfc7921dc4ae2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-CondensedLight.ttf", + "size": 54204, + "sha256": "7bb2b8f3cc352900b0e1f16f168af9474bd0c53fb86f710847e2ee5ba7690b09" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-CondensedMedium.ttf", + "size": 54260, + "sha256": "0b6bee3a066197e655eb9c80a2eb889659182db206e2067355d1438aa8e83f24" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-CondensedSemiBold.ttf", + "size": 54640, + "sha256": "6712734abffed951eb1cf4d787c9d00420eef8d4aed1e29e1e63970458ef3ed1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-CondensedThin.ttf", + "size": 53352, + "sha256": "fd8874e6a5954f70c018c31bc27b160549e4b35214aa888511b0b6dc3de254c1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-ExtraBold.ttf", + "size": 55940, + "sha256": "4a73e2a52fc03a2dffb6f418132e5eb7de1ef9ce58d60b5293d4df12e321b9fc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-ExtraCondensed.ttf", + "size": 53912, + "sha256": "4865971ab768736406d96ad41d55e2fbaef2d1edb66de829833180b674f4e7ac" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-ExtraCondensedBlack.ttf", + "size": 55828, + "sha256": "e8dbfe7e4a79d29f2fcb4cd8f4524370bb25559a426cd5c346d1ae8751969c81" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-ExtraCondensedBold.ttf", + "size": 54080, + "sha256": "a56c6a6e587f3251e10e9b3068dfce7caec4e9313736322125a4af3fe374f90a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-ExtraCondensedExtraBold.ttf", + "size": 54732, + "sha256": "ffd45b4afbb2529318eefd0eded8b8a798d552140f0264d79f690ca3cd171930" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-ExtraCondensedExtraLight.ttf", + "size": 54860, + "sha256": "bcba921c16fbe0ffc6462cae182e4e4a22c0f06b94a15341f0b7090a2b799ce2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-ExtraCondensedLight.ttf", + "size": 53808, + "sha256": "0cd80a77eac2c428b7faff761d8de6cf0a12a3c40cf18a9e4e10aefe887b648e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-ExtraCondensedMedium.ttf", + "size": 54528, + "sha256": "16a89bbfe00a27dc85870a02c973d6e19d2f5013ef3a885cd5e3acecc8b9a809" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-ExtraCondensedSemiBold.ttf", + "size": 54812, + "sha256": "86f3f626e78b2ed3f1c5fef9a8266ca1b062dbf724c86e1887a5af569da423b3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-ExtraCondensedThin.ttf", + "size": 53312, + "sha256": "bb687f1822ab09682c8acce71aa4917ba911058acebe809fc456905f8ca9e746" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-ExtraLight.ttf", + "size": 55120, + "sha256": "2175aaa5090949e88d25c3fe22e2d1f421b5ee8e73fe7d8bf7279cbb4d47ff73" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-Light.ttf", + "size": 54792, + "sha256": "834394e97954d43767bc6a8a6fd6db0dc879e5051133632139621eb24e5302e5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-Medium.ttf", + "size": 55260, + "sha256": "f7e0a2184434ca064304a2c727b7ded8584049e4b12a7728109b7885a665f75d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-Regular.ttf", + "size": 55172, + "sha256": "658d0207da305a1411c539a8b0bbeda64d4146e54fb4827facddb890b6b90d74" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-SemiBold.ttf", + "size": 55172, + "sha256": "f8a54df867ccdf5e329877be789e6fc94558e9adb7754e89900368af7b983870" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-SemiCondensed.ttf", + "size": 55048, + "sha256": "a039c5c5da0a0dce752ae20c238e6b1fb54bd853c7e2ac95c00747d29146aace" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-SemiCondensedBlack.ttf", + "size": 55788, + "sha256": "db2b9f36f22159ed854e627d58d2a98c35c7ef7c09c27ac3b5908d38ed02b40f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-SemiCondensedBold.ttf", + "size": 55164, + "sha256": "1c1092758ad7e8016fb046d2342eb2e9ba5a92a8ebc5044e7b989b26299dce2a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-SemiCondensedExtraBold.ttf", + "size": 55208, + "sha256": "2803a382f933a2f5380dc3d64ca4ae141ef8899b6416d6c9f9f67e20e419146f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-SemiCondensedExtraLight.ttf", + "size": 54868, + "sha256": "51fb620a05c86363d8cb5d0cae6fbb3e981b700defc40c8fe8abae4b3368fc29" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-SemiCondensedLight.ttf", + "size": 54224, + "sha256": "6ab87fe49291490d97c6ba15ef093e3c3d015a56469457ba69ae7181f45d7d0b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-SemiCondensedMedium.ttf", + "size": 54876, + "sha256": "4887a95f45303f96ff088e10e380617fe7dae14a57392978b6f1d4927aee9dc7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-SemiCondensedSemiBold.ttf", + "size": 54996, + "sha256": "85d8040ccacb231e29605556da1672dd99bd6f412a395a988eadf047ff1e0455" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-SemiCondensedThin.ttf", + "size": 53428, + "sha256": "4ba6c7bde1efc83c40e1ac0ab58efedd0a468ec3867ccdec0e2ba13423e1f239" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhi-Thin.ttf", + "size": 53752, + "sha256": "f0e20ec51034b332ffe5df48cdc709c890991095acfe20e22878791d76fa0869" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-Black.ttf", + "size": 56932, + "sha256": "69469fc8c92febae2aa66c7c3459dc890ef359ab3e315b65215f8666a8c42df0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-Bold.ttf", + "size": 55144, + "sha256": "125bc4d85cbc5008ab2b622f519f29e940d7f8de7491067c37ab2db2b105fe48" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-Condensed.ttf", + "size": 54200, + "sha256": "c28a18db5ca968f87d8c0fad5415ecfd8eda9cb49229a9fb010c42f51e344f0d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-CondensedBlack.ttf", + "size": 55500, + "sha256": "d70c947217ce73214ce428931aa84799128993d4f964cbdfb5f8f05c918ad47a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-CondensedBold.ttf", + "size": 54752, + "sha256": "82cbfefb4ff8e214ab8776e89eedf8c27fecd73c1a0ed240e69ed0af8a7ebff3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-CondensedExtraBold.ttf", + "size": 55156, + "sha256": "d25be06f4640066b6f511f16587409401cf967140ae608a50d8b07bf7436c299" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-CondensedExtraLight.ttf", + "size": 55168, + "sha256": "f01e4c7d8f127a770b1c78c31140656da8fc5200441b4f435dd24578512b7781" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-CondensedLight.ttf", + "size": 54256, + "sha256": "a447c421b4eda1fecf43350287ac604b31a0bee8724604ac687dc905fed9f670" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-CondensedMedium.ttf", + "size": 54308, + "sha256": "40386c3ceafe7b99c7ad8d651e841d878e4de30e339ca3d1eb912dfa7ea0899a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-CondensedSemiBold.ttf", + "size": 54684, + "sha256": "ea205809ff5a9e3aed06217d7945a7f7711f18bb14600e63189a815625c5f7ff" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-CondensedThin.ttf", + "size": 53404, + "sha256": "cdf75d4b54b2de6bd1be9e31c7353ca3e890c9c60eec860bd8fe0fdbc75eb2d2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-ExtraBold.ttf", + "size": 55988, + "sha256": "cacb0830e57a6569599e792beeb47c9a366e3bfaaf4b7e0b057ed58f1f931073" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-ExtraCondensed.ttf", + "size": 53960, + "sha256": "98aa9ce036da1d9a87f78999a19024414380d393403715f35a715924e922d459" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-ExtraCondensedBlack.ttf", + "size": 55868, + "sha256": "39d601cf2feab994e35d2bdf028afbf300bdbb33c26a50978b0be7b14275ea18" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-ExtraCondensedBold.ttf", + "size": 54124, + "sha256": "8b96200fbc00bd388862bd0fb7d60c7924870f153abc4804e564bcd26722cd5c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-ExtraCondensedExtraBold.ttf", + "size": 54772, + "sha256": "2a871793186512d57560a8fc7ce738c78b618d176f671825faa409a3176eba28" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-ExtraCondensedExtraLight.ttf", + "size": 54912, + "sha256": "73c20eae776251d0184dc4f79d405c37d78692911d486bc46080b2dd14116f64" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-ExtraCondensedLight.ttf", + "size": 53860, + "sha256": "abb26b06f1be1a09c74d346b358d7dd27122652198b0ff15899c5e260235f994" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-ExtraCondensedMedium.ttf", + "size": 54576, + "sha256": "0ec8f733b3d006b6b2735486a08df94320f9deb5ff3077059069086722af709e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-ExtraCondensedSemiBold.ttf", + "size": 54856, + "sha256": "8652c338011588cb08086885f6f759b2950341a1a1954c150b07f6ebb0dd4fa5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-ExtraCondensedThin.ttf", + "size": 53360, + "sha256": "9c788f49c4fe92238547e3e86af05f9ffa19283510582119d89d3351ac75006e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-ExtraLight.ttf", + "size": 55176, + "sha256": "39da484e67a75fd2975e5796423f3c728725605b54cc4d54cb89251377b8cb68" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-Light.ttf", + "size": 54840, + "sha256": "affa2b8dd7bc3d05a2a4fe3deaad60bc2b0d0ba784c2c6fd17d927dc4067c22a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-Medium.ttf", + "size": 55308, + "sha256": "c4ffa67e4f3147431a3f2164397f0a4dbdd37b95c7defd1ff5bf1e385a404283" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-Regular.ttf", + "size": 55216, + "sha256": "e6a359003e994d015ac7a74ecb03f8f2892c156b9c26f78e03dfc0780ae226c8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-SemiBold.ttf", + "size": 55224, + "sha256": "fc1710ed7c905f061295ced040d12327191c69d5f2462f46c8655a9d41198ca6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-SemiCondensed.ttf", + "size": 55092, + "sha256": "9152342500f4f4d1a87ebe80e39f459dadadfde9165042c63564a10e60ac992c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-SemiCondensedBlack.ttf", + "size": 55836, + "sha256": "29141893d5b528b5a4dde8cc7b89f54e503b28c77f6e9b8ee67afdcbcb8a3dea" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-SemiCondensedBold.ttf", + "size": 55204, + "sha256": "dfe9aa9a89772b203752e0e5985dbe1be1617325cb6ffc7e60c9b4d8f9bef7b0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-SemiCondensedExtraBold.ttf", + "size": 55256, + "sha256": "14ed006c1617a0566974001b0de907b2e673a083efc51bf8684e44652c153374" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-SemiCondensedExtraLight.ttf", + "size": 54924, + "sha256": "2b38b5996c2c9b7e66cabb4f7dde1dfb26c5eed7d61d542f88df68e4e84dac39" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-SemiCondensedLight.ttf", + "size": 54264, + "sha256": "59ddf553c913dbadf67e67960c2fc77dd2f0ba78a0b4a741d9e802e3ca8e3adb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-SemiCondensedMedium.ttf", + "size": 54920, + "sha256": "d9c61c09cad05794f1722a72b7f53b8b3461636da080e5c541544eaadbdcc8f0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-SemiCondensedSemiBold.ttf", + "size": 55040, + "sha256": "7fbb0bbd859cf54894adf87315fd489a9195088f099dd738ffcc478f9335918f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-SemiCondensedThin.ttf", + "size": 53484, + "sha256": "32e85d37cfb93a74233a2a25374eb6ccd2078582fb6a0bac52987809aa2a9193" + }, + { + "path": "/usr/share/fonts/noto/NotoSansGurmukhiUI-Thin.ttf", + "size": 53800, + "sha256": "776a06e8da5c82bec9b65310f4eb6d0921a330ba37980d95d2395af4212af69d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHanifiRohingya-Bold.ttf", + "size": 28016, + "sha256": "55227f7c88a06112c9277bedd50fe4666a71171167d2bac5937125ea61b8cf96" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHanifiRohingya-Medium.ttf", + "size": 27744, + "sha256": "efd660ee7db397cc194627e4673d949f2bd13d2da9e847b3ffd2179187883cd4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHanifiRohingya-Regular.ttf", + "size": 27580, + "sha256": "ce453eaf8807a9a410cdc2ebeb7ae009e90b9e611342ac239aa59b794bdcefdb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHanifiRohingya-SemiBold.ttf", + "size": 28208, + "sha256": "d5377ae0a3762907ffffef944f55aa917e0e940b0a73d0bcb90617ef996da396" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHanunoo-Regular.ttf", + "size": 7668, + "sha256": "ef23d153e9d666becc0d79fa88f0ae21f46138f1285b8eac304661ab35717aed" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHatran-Regular.ttf", + "size": 4756, + "sha256": "b54b267ebfc38cc63bc233ef31af77c2c0ebcd00cbc49b38035c5923b12ba54f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-Black.ttf", + "size": 26784, + "sha256": "242aba3df2f2984bcc090c747d7d399018b11027b7c22f600a15c361356dab3d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-Bold.ttf", + "size": 26860, + "sha256": "da9226e886c245a7e11673c24dec82bded64d8574c1e1f03983bf89297d2aaa8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-Condensed.ttf", + "size": 27052, + "sha256": "c246fa5f743b9aeb187b15b1242f89cb75c71ba2f1a8e97644278847d24450b1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-CondensedBlack.ttf", + "size": 26964, + "sha256": "3a194fd5030b392a79f404a924fb85bc2500b5cb1f652c9052b23517da78d6c7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-CondensedBold.ttf", + "size": 27116, + "sha256": "e87ae357d26d236735fb46b1bfd305aa077a65770434d5e409cf9a8bdbb72956" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-CondensedExtraBold.ttf", + "size": 27416, + "sha256": "b7fa67877661a9725513fb59f338e2083453e4e94050203c066c4701a4c9c2fe" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-CondensedExtraLight.ttf", + "size": 26964, + "sha256": "74d6d6b102350778e4a56b3a0fc9700af037e402ef8f132d45d090ad850ed9b1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-CondensedLight.ttf", + "size": 27564, + "sha256": "656287f56976f9a97b129afaa5c3cea643698de7f6f7badd9f1977bfe7b694a0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-CondensedMedium.ttf", + "size": 27052, + "sha256": "05a599bbae58ab47be00bcfde752d3009ccc1b05ec21de0ed60fe5dc10df21f1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-CondensedSemiBold.ttf", + "size": 27240, + "sha256": "bcb6ac134d2734e623af544c1991cf73d99a750313a1ef1edab02c00877aa6f7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-CondensedThin.ttf", + "size": 27192, + "sha256": "badbf8c1b7272c112395abd9b257a8f408f487a59b96026ba5c04c32372a7b35" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-ExtraBold.ttf", + "size": 27076, + "sha256": "b65adc0f7f2fab913a9aa2b21a4486bbc20f08171b32a64ffd93851230078ad4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-ExtraCondensed.ttf", + "size": 26928, + "sha256": "0b3c97bb91e8b8397972ca70b7256cc1c23e861fe6bf279a7f4b59e5867921de" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-ExtraCondensedBlack.ttf", + "size": 26844, + "sha256": "5468fa5848ca8b263355d0dc46ed5c063b6f4b114cbb41f1f7e64783cb85ff63" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-ExtraCondensedBold.ttf", + "size": 27152, + "sha256": "e0bc09960221c359a161a5a16cda03a6b32f493adfb70a54be9a7af0d9e374ce" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-ExtraCondensedExtraBold.ttf", + "size": 27204, + "sha256": "52e20203087c797aa50779ce5291f647dc8e56d40a3d8073d68d452e27616d7f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-ExtraCondensedExtraLight.ttf", + "size": 26676, + "sha256": "d6b2a740099261c8d5bca26f5967cca3dca649c670ce963f848713afe425dc20" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-ExtraCondensedLight.ttf", + "size": 27276, + "sha256": "cfa3d33a98e48baba6c8701d5bb8772f0069405aa9c1bc0b1866c259f9cad2c1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-ExtraCondensedMedium.ttf", + "size": 26804, + "sha256": "0795dc36b76aa8133285f43bacdb2439d43ba7c912e93a7c221a179c6b8cb61b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-ExtraCondensedSemiBold.ttf", + "size": 26888, + "sha256": "8ccd881a6b5a390721419ed96cdf4452d12ff432778020c2e0ae565a58a3081c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-ExtraCondensedThin.ttf", + "size": 26980, + "sha256": "e5d566ff99a6deafced02184b076f63632e7b7a76fee4663179057d51d70351f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-ExtraLight.ttf", + "size": 26812, + "sha256": "7fac562cd2f5b9d70d0b054e4d8f8e67cafacc0b78690f4c3a04362158631b9c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-Light.ttf", + "size": 27448, + "sha256": "8c7b8041c7a9136c80d1a9d002ca0f839aae3b23a85c33c6b5d93ef456b3a675" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-Medium.ttf", + "size": 26920, + "sha256": "ae3d724f56101a961f6824c030ff827e3fcad4ec9eb5a902047506f00bf9516b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-Regular.ttf", + "size": 26860, + "sha256": "cdefaf8efd47045f6820928eba84db5bed7557539328952b5f828315485e02ee" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-SemiBold.ttf", + "size": 27100, + "sha256": "1554039810a7059296f4eea651927e32f7219dc6f220ab70e449c69906769c95" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-SemiCondensed.ttf", + "size": 27088, + "sha256": "fa2aae6ff1fea39bf1bd2dd5feff0bb89a915edeaf4bbf1d23cb1be32682691e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-SemiCondensedBlack.ttf", + "size": 27064, + "sha256": "e7099906f883fc46d736e9dfd85cb775732c0084eccf50e76eec6f8cc908636c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-SemiCondensedBold.ttf", + "size": 27196, + "sha256": "c156a4a1eb7f43eb780ff18f7b3faa32c7512ac38328633e8692485f42f51266" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-SemiCondensedExtraBold.ttf", + "size": 27484, + "sha256": "3b4519b35fdb42b8f4632e8d7848c2f46c6a8b976457f2217f7f3dd307f222df" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-SemiCondensedExtraLight.ttf", + "size": 32008, + "sha256": "8f4266ed73f51541825089babf3d5f2f0f01e51fc1038603715a410c02b98d13" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-SemiCondensedLight.ttf", + "size": 27608, + "sha256": "026562629cf3c2a90c6dc6835889117d2ad141deb70ff45de7b35d4c251c7cfb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-SemiCondensedMedium.ttf", + "size": 27228, + "sha256": "4274802620cb652f7154372119a909ba8e14866c38549ed909f0f953982b9b62" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-SemiCondensedSemiBold.ttf", + "size": 27296, + "sha256": "7c82958f2e210b506555de47879cb48ac369ae81da8bfa8cb9720c639653813f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-SemiCondensedThin.ttf", + "size": 27352, + "sha256": "97e8e59e4b1666fb0be73a5812771be6427dff789579673328f1364401942130" + }, + { + "path": "/usr/share/fonts/noto/NotoSansHebrew-Thin.ttf", + "size": 27184, + "sha256": "fc4ccb6b958c84f7a3d4bfe22347304a59a15339f624f0e5861dee3c9248cfad" + }, + { + "path": "/usr/share/fonts/noto/NotoSansImperialAramaic-Regular.ttf", + "size": 6212, + "sha256": "5b3bd3e7b17591a957bdb22fd411202e37badecabe6386a1b419e60116e3199f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansIndicSiyaqNumbers-Regular.ttf", + "size": 32288, + "sha256": "68f41d31c85653a9c45d6d7e903c4329a791aee3a8559c2d19ecc88fa28090ae" + }, + { + "path": "/usr/share/fonts/noto/NotoSansInscriptionalPahlavi-Regular.ttf", + "size": 5648, + "sha256": "1776a4a688e99a5c9720058e798fb82775b5763d57553f8912ef0fed3433ccbc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansInscriptionalParthian-Regular.ttf", + "size": 7508, + "sha256": "70c2c24e5442cf4ae70af6b8274fccedc4807317fa7c3cf78272349c35bfb506" + }, + { + "path": "/usr/share/fonts/noto/NotoSansJavanese-Bold.ttf", + "size": 113336, + "sha256": "214cafcde93100c6d0442c27589f4acc0bf091ea72fede1471e6490ede3a511e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansJavanese-Regular.ttf", + "size": 120504, + "sha256": "81fdea70d379989bafea65eae5a6a96144991b437415744716a49a56f09f747a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKaithi-Regular.ttf", + "size": 69684, + "sha256": "ba33fd78d18df942260c53df4080254901aa7c3b69678c7571d680bf063a5e2d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-Black.ttf", + "size": 204368, + "sha256": "f2d17bf92c870765c40147d8cd25ba20f0fc56b5f5ece21ce2fb7387e781c28c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-Bold.ttf", + "size": 187208, + "sha256": "3db1507b3f856185fc7c00dc0addfd4797a8ea6955b86d1d21fa659cc9d5e849" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-Condensed.ttf", + "size": 183200, + "sha256": "ea5e95fe7aceb00a38824c6b6db7773ce529ea8d9f6f91d7bf26f99c8996ac46" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-CondensedBlack.ttf", + "size": 202852, + "sha256": "0c626d37219b6ee05a7079c1bfde7dcad2463ff94a08ff7eff368b3374860a69" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-CondensedBold.ttf", + "size": 189256, + "sha256": "b5d170a873633075206003a87655321a991290c8d87c6f7af1a26d709c0c189a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-CondensedExtraBold.ttf", + "size": 192812, + "sha256": "79b0c8b2452639b4b0f9bd7a8e32b93fb5d4786340e0102cafc67de1e2114ff6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-CondensedExtraLight.ttf", + "size": 178340, + "sha256": "6bec04289587d6ecbe8d32c1045e0f040a6ed4645fc26cdf9f4bb086660ac58a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-CondensedLight.ttf", + "size": 177932, + "sha256": "99408e36687ea88258a396ec37cc1ded4c0ff0c351f1c51c665fbeb5aa8ff2e8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-CondensedMedium.ttf", + "size": 183256, + "sha256": "8390056763e14dfd1be3eb53e998e30cc4ff25cc2784b0de9cc6615ed0a616f9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-CondensedSemiBold.ttf", + "size": 188952, + "sha256": "9c5cff64514d2d44e9ae2b35340a38bfb89cf7973220e1683d3d87265281d66a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-CondensedThin.ttf", + "size": 176588, + "sha256": "505de1e6d7334bd752142bd288e251a860cfe340ea9848050307c118e303f9f5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-ExtraBold.ttf", + "size": 194536, + "sha256": "71d75eab3c6151918c2ccc2de5f1d494c1c8dcd9415325e5ab172552ea0e40bd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-ExtraCondensed.ttf", + "size": 180952, + "sha256": "cf4e1032dfbbb141c54d60965b657dd689893cc139d4b1d98ce725ef202292e1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-ExtraCondensedBlack.ttf", + "size": 200868, + "sha256": "f93656b442b62b2bf404a6817fbcfda54889fb254ae7e6039acee728d97331b6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-ExtraCondensedBold.ttf", + "size": 189284, + "sha256": "bddace5f149c7416d32618e2438afb052fd03504babd6efcbb7230aee5543248" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-ExtraCondensedExtraBold.ttf", + "size": 190076, + "sha256": "1b6140f101348cd2062c9a852e57f7ff1631351849590e1afd94c94b06151fcb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-ExtraCondensedExtraLight.ttf", + "size": 181364, + "sha256": "487aab6bdf720af4eaaa155356c0a179f223ce62284c97cd59183fc85c99e7bd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-ExtraCondensedLight.ttf", + "size": 177288, + "sha256": "d0afdf599a53f09528c8f3ab5140b1dd3405120811454a765e443307cc6be17c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-ExtraCondensedMedium.ttf", + "size": 182892, + "sha256": "d7417602dfa32ee91b58ae13ce68b3f252f47133131bd54d8515dccc9cb37a11" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-ExtraCondensedSemiBold.ttf", + "size": 188320, + "sha256": "53ab26984466231748077b8edf29fe635a07e2418a9a4ac62f344042ceab08b5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-ExtraCondensedThin.ttf", + "size": 177276, + "sha256": "62f8cf74d15e866c7c215f58a61b6bcb4b688721d39d0d5a05f59a01c520dfc4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-ExtraLight.ttf", + "size": 183764, + "sha256": "1e1cfd0537c39b25a6d83d2576879961e73e80d2dcfb278ffab3b3a7a2a974fd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-Light.ttf", + "size": 179480, + "sha256": "e7c876b371b228cb33cbbfaf11a83cd89281f2546fdc1aa860c7a2907b42ccee" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-Medium.ttf", + "size": 186876, + "sha256": "0316c79da607580db270196574386c8df0f30a94e9036a108dba2dcd5228a42b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-Regular.ttf", + "size": 182416, + "sha256": "9ad74dc64838c6855b96f671fc08e425a58921b9d0c71712ea79c328a27e6e38" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-SemiBold.ttf", + "size": 185000, + "sha256": "d9329776a3d55fb4074b9dae945dfb071632d17dfb440470f887356613f60fe3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-SemiCondensed.ttf", + "size": 181372, + "sha256": "445a3e4574ce86105c3e5cb599185fc602b314cd071bcc546cce5c292b7f77ac" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-SemiCondensedBlack.ttf", + "size": 203060, + "sha256": "ec35aa74e0f25dd6c1f8a8eef1fb1d47c45459516a66f19b47a4c2daeee097b0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-SemiCondensedBold.ttf", + "size": 188476, + "sha256": "7344a1ca962a0e22ad2ba09ab95790b4e652e3e1ff6b7d95368cc6828f6a2038" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-SemiCondensedExtraBold.ttf", + "size": 193556, + "sha256": "7de8be1a629663f8ed92b32a78b9023827756a7f8c8ee6453dc01c9ef1427462" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-SemiCondensedExtraLight.ttf", + "size": 178668, + "sha256": "7fba9164b3e363545b57ee0356ef136ba9f4d57181ffe5e595c018957fa88205" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-SemiCondensedLight.ttf", + "size": 178556, + "sha256": "4a704bad614f177aefba1f76bb3afec3592653f4defb4d928d8df6ccde6fcf8a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-SemiCondensedMedium.ttf", + "size": 183300, + "sha256": "e3c6d37d6cba40bcba4ea7299c59667ff734401ecb0d476961c64dad8de818ce" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-SemiCondensedSemiBold.ttf", + "size": 185296, + "sha256": "800c1c41fad609a20d6dd51db294a0e67adfcdce35ee5a9790ff2db75a899a81" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-SemiCondensedThin.ttf", + "size": 177980, + "sha256": "87cdb62d32c70f1682114f8b6632820729ac3c1386d516d8cb69690378106780" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannada-Thin.ttf", + "size": 179980, + "sha256": "b017b37587a01f59d99904b222e101b2fb595c22c9c8422cca2f641501344286" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-Black.ttf", + "size": 204856, + "sha256": "862e2c26da3fdeeba884d5c2263f3cfad2268ae4ae15bbd1689d881b4a00e1d6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-Bold.ttf", + "size": 187396, + "sha256": "19d308e515ceaec155a6775c404f68e52cafdd5cf70b39db3a114f12752d05f8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-Condensed.ttf", + "size": 183288, + "sha256": "d6518f341729119d041d24aaf33dd7616ab89b605755ad559460947bc146f757" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-CondensedBlack.ttf", + "size": 203184, + "sha256": "524bedbb214bcbe3c3e7b361938e61187bc14b86fcae62880f7d7dbcb114a68b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-CondensedBold.ttf", + "size": 189404, + "sha256": "6c1bd0c43f7f301d43af252aa8d56595b3971b7a99794fb61a56b8e34b0d6e97" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-CondensedExtraBold.ttf", + "size": 193316, + "sha256": "d77a8b084e24b19134beeb906a310f391805b755ee3527754f7c72363add3a52" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-CondensedExtraLight.ttf", + "size": 178660, + "sha256": "1b407e61358857e383930428ad6ea5cc9814147574543c95bcfa7e65dc529ee1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-CondensedLight.ttf", + "size": 177964, + "sha256": "c962e884c74fb29897af36a1bc45e1d83273baa45907f8595b2d228e319a03bc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-CondensedMedium.ttf", + "size": 183640, + "sha256": "7a4badcd44f1884e0566cd415a7c6de2eeaf46e3c26909953c9711c4038ecadf" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-CondensedSemiBold.ttf", + "size": 189072, + "sha256": "42b80c4185d34136777c091cae7b4e756f62f235169397f21ff39bd1f3120b39" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-CondensedThin.ttf", + "size": 176612, + "sha256": "f1470d8e58775edce24dec5c44cef30abfb2c17f66e4c88925dc1806fcc43615" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-ExtraBold.ttf", + "size": 195128, + "sha256": "d4c82834883ed3f826c480d5bf0ed8c00f2a1063505758d1418784b6556ba781" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-ExtraCondensed.ttf", + "size": 181028, + "sha256": "a33744a3348e61c9979d15f1b7bde8f46ec08cf3497387707d565366c71bfd42" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-ExtraCondensedBlack.ttf", + "size": 201380, + "sha256": "f2d36b2e1967158a956e8cec59e6b6095d0562d9daff2eec95ea1c3ffd37bdac" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-ExtraCondensedBold.ttf", + "size": 189460, + "sha256": "a3058501cf6675e5c65e61b8fb0be5bcda9d4dd1fa066f619ba59dab9d6a55dd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-ExtraCondensedExtraBold.ttf", + "size": 190740, + "sha256": "1156d93c285d153119616999cb4662dc1f7c353f545ebc6126aa54ee43a493c2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-ExtraCondensedExtraLight.ttf", + "size": 181684, + "sha256": "7483d135d54fffc82a842d5339d8b2449f055cca02e6e9f41816eaf839e17042" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-ExtraCondensedLight.ttf", + "size": 177308, + "sha256": "1c3e46a92f9e846e9086ee757e58fd9d5dcff7550c950685ccb2d5c9399f7dfd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-ExtraCondensedMedium.ttf", + "size": 182988, + "sha256": "4199c78b995547ddac2d541e0a3faaca3dee92d0a94d79c8b2f44053061618b2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-ExtraCondensedSemiBold.ttf", + "size": 188472, + "sha256": "1dbbb128d850af601eed0378f17127bf84d271ae4787b7cc79e98190443d8fb3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-ExtraCondensedThin.ttf", + "size": 177300, + "sha256": "ceec738ecdd41bad31f0ab675c764515446d9a91bcb4a5d283cda07764fe2f2a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-ExtraLight.ttf", + "size": 183792, + "sha256": "d36ba43402e0824952d72c19972f5537728f2281fff94c1c149878e1e319e4f6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-Light.ttf", + "size": 179508, + "sha256": "86ef50bfdaf59b666f5549597c0986890ae849b22bed1409ef8aa44f162100c1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-Medium.ttf", + "size": 186888, + "sha256": "a5efe38f789c7e670809cb411aaeccad78d419f5647e98faef01a07459ebaec0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-Regular.ttf", + "size": 182504, + "sha256": "d56c1598ef25773673400e7174c47b0a803c007cd6c4a302dc8fc99897c3a019" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-SemiBold.ttf", + "size": 185088, + "sha256": "a57c57468fcfb4c82aa694075e71d9cfe945d1c4400fe3ff47932ba8570b605f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-SemiCondensed.ttf", + "size": 181460, + "sha256": "b79ecf724e1fb512a4f2a28e43754f2387ce6353f28dba9907b4a46c9dbead22" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-SemiCondensedBlack.ttf", + "size": 203672, + "sha256": "7db6a551ea23bf85c270b8287d85742f31c6c9e2811edc492e78d0ff4b59ed44" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-SemiCondensedBold.ttf", + "size": 188656, + "sha256": "9e06fd7eebd5a34c05288a51bcca07a2543b61f0e00959698853db3f0421bc84" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-SemiCondensedExtraBold.ttf", + "size": 194172, + "sha256": "5c3ff048c5abc50c0144fcfe9ea1611af483d9ded7ae8b6f90d174a7d926e11d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-SemiCondensedExtraLight.ttf", + "size": 178980, + "sha256": "2eb796cefb06f5b5b5f8d8fb22e03cbc9a55069c687adc843df3dc5a4444978b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-SemiCondensedLight.ttf", + "size": 178588, + "sha256": "dafe251b51937df682b94a2887ec7fac2e465eed427c9b686d7cc27228276f23" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-SemiCondensedMedium.ttf", + "size": 183380, + "sha256": "c9a844952a6d3b64cae368fe5667d33281c2189a0ac2a532a161cce472b16ec0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-SemiCondensedSemiBold.ttf", + "size": 185388, + "sha256": "418bdbe75d9ee0771fecc0b33e4dfb92f01a32e67ba1e89102ce7b37fca58431" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-SemiCondensedThin.ttf", + "size": 177996, + "sha256": "a4704ca0786c1fec312a82dfea55f0d767cabf3981c0a3d28260ede7c3066cbf" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKannadaUI-Thin.ttf", + "size": 180004, + "sha256": "7c653598850e5dcbaaf22654234067f0b77fa77c62015a0ce39dd4beb9d3fa42" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKawi-Bold.ttf", + "size": 39508, + "sha256": "de10afa348098bb2e57c0eaa8546f6af9066450ec675eacdcc00aa6b5f3f3942" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKawi-Regular.ttf", + "size": 39844, + "sha256": "61da5fcc6d5057814b7a940ec3cbe43cc24c64f8f8cd389965e4454ccc6e7a6a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKayahLi-Bold.ttf", + "size": 17424, + "sha256": "19d83b96fcdedad6c60c735b19304f2bdb4dff00bd8d950973e5d9fcaba209a2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKayahLi-Medium.ttf", + "size": 17564, + "sha256": "c0dd173b1a0740d5270d08be659936e3c37fc86391343c5d32dc467a99c3b1a5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKayahLi-Regular.ttf", + "size": 17428, + "sha256": "71235e4d51c4e591b20717c525997c72ee906a9d39ce55f8f8bb8c60e96ab807" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKayahLi-SemiBold.ttf", + "size": 17340, + "sha256": "85d39fa06ceda465eb698e92d1429408f435cb5e713dec39b1cc01ca135801e8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKharoshthi-Regular.ttf", + "size": 34252, + "sha256": "65962c55e28808d8677810f4afe9f3d7af57508a19632628376a34bb3ecfe0fb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-Black.ttf", + "size": 119792, + "sha256": "ff591b8bd1b6a339943a69e788ee3c4f80eeaa3c272fda6ba43e5457a60f0188" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-Bold.ttf", + "size": 113740, + "sha256": "17e5bb518c0ea3d998d42bf920d0771a79dc6f3c12c5d9f3c122c7e6c8ce3076" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-Condensed.ttf", + "size": 109288, + "sha256": "b4407f27f63b487188a1f47a16146d42db15c196b39183e95d13ed3f426af20a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-CondensedBlack.ttf", + "size": 119088, + "sha256": "ccf176b5a1ba42c9c58739b990455ab68754bbb1a92545664e864d257fa793c5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-CondensedBold.ttf", + "size": 112448, + "sha256": "6254ea13ce96afd27896aa1441ceb350f7911a3568b7582a92b2755d447b1025" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-CondensedExtraBold.ttf", + "size": 114700, + "sha256": "2c5849392abe2a4819428021e6d29993ea87c5ddc81780bf56dbd768a6bb9bef" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-CondensedExtraLight.ttf", + "size": 112772, + "sha256": "2234ba56d06174264c095127428264db6e9a2e0736685bd56fd30aa511cb00f6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-CondensedLight.ttf", + "size": 110552, + "sha256": "e5adbe4f3fab991f6b044f063f2b89fdd9b817b4d17f4568016c5d1fd030c06b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-CondensedMedium.ttf", + "size": 111436, + "sha256": "cb5e0ce9b48ed1879505ec74cfcc6e1cd0ecec429bbafbbc1817844a87c39ac2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-CondensedSemiBold.ttf", + "size": 112648, + "sha256": "6454e2090ae74b74760cbda4a562682660f74d8b00f41d465238c83992db25b5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-CondensedThin.ttf", + "size": 110860, + "sha256": "504f282d170a0be9ea2981d9dbd5cb4a805c5bf0a0356dd1839a13368a7f24af" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-ExtraBold.ttf", + "size": 115780, + "sha256": "2a484d010e60e7a1366b8f3ba5c02aee0b7c032b146a0a98a6c824fc6935cfa6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-ExtraCondensed.ttf", + "size": 109288, + "sha256": "2a26ba56fdef157d3f5e79f02c8b525b41f338d3d2c979f696310314582b81a6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-ExtraCondensedBlack.ttf", + "size": 118728, + "sha256": "3e600a9e01abdcce41d90d69877495efc22806f19500729cf2594d6117d79fe7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-ExtraCondensedBold.ttf", + "size": 112300, + "sha256": "3498bc1e2a93b263b222ff93fbd6521110b9b16020f2ea366c2de9f8a733027a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-ExtraCondensedExtraBold.ttf", + "size": 114376, + "sha256": "bbc7cf72f877073e569975a2ca440cd1a120b08b415c9850f6634e38092950fe" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-ExtraCondensedExtraLight.ttf", + "size": 112796, + "sha256": "ad340428e8d10a3c2caf3816b152dc277ff35b99c1369e9b455af535c689f22d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-ExtraCondensedLight.ttf", + "size": 110188, + "sha256": "efd59bba19100d65cda462ffce8ab16918418fe6fc71f498a93634869c8b5b98" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-ExtraCondensedMedium.ttf", + "size": 110560, + "sha256": "b2ab0da876e8a6da14f765583f29c39e5b1fc4cded0ab0ede0e1304be2d5a2ca" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-ExtraCondensedSemiBold.ttf", + "size": 112608, + "sha256": "98577fe99827aa69e8cfb163f57f515ef53bc2173a8fe0e3186fc7094b94dc97" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-ExtraCondensedThin.ttf", + "size": 109228, + "sha256": "c2dfff1c0296964891a55ae9b206ae383c06592e9cbcf486f86e751514bb33d5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-ExtraLight.ttf", + "size": 111964, + "sha256": "e92d39d877aa327564dbd88fb4725afbf34484a587da1e880153f8e145a20df4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-Light.ttf", + "size": 110600, + "sha256": "fb423de9e6ff668a50d9da044963cb4be2884d9d8df9789770c07e751432d711" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-Medium.ttf", + "size": 114172, + "sha256": "8546350046c0391088f3d9c94a688befc14e27c1500b1d96fbe55d3e665affbe" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-Regular.ttf", + "size": 114576, + "sha256": "e66675f2082788f0511a714bef5a1748928294b38c8e286a96ea73a864b5e605" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-SemiBold.ttf", + "size": 112796, + "sha256": "9951be2e5e1f0439f27105a18141eab409532d6037e3431a724ade666e639cb0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-SemiCondensed.ttf", + "size": 112572, + "sha256": "94fb1b711c3691ec00efc43403cf2710c1fbf7f1533b59891da8a64339308857" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-SemiCondensedBlack.ttf", + "size": 118256, + "sha256": "149565ef41acb7ea5f5d4b51f53a1a8da8134860dedcb953c54ea296256e8e97" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-SemiCondensedBold.ttf", + "size": 112540, + "sha256": "d50fec7e2360ffb33cd609a9d1d54c93de60435dffca6e843f46440e45ebae8c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-SemiCondensedExtraBold.ttf", + "size": 115060, + "sha256": "a4ba7d2ac6f442e241f5f314c9b129ef9aa0dc77a3baf14ed49fade8a1a78a10" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-SemiCondensedExtraLight.ttf", + "size": 112884, + "sha256": "f1ffd40403d53787b097c798d6e788133d4e4e6315d53cd447c68d209e277e7b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-SemiCondensedLight.ttf", + "size": 111048, + "sha256": "d2685f8064a1473f6bc1758d13ece281cf24318e101760eb8822af543b3e5d62" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-SemiCondensedMedium.ttf", + "size": 111084, + "sha256": "5769dea1f80213bcba05d9568aa7a0b45f5d5fac0d34198c15a42cb36770b9e1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-SemiCondensedSemiBold.ttf", + "size": 112700, + "sha256": "44f2d5d7432a1841da81c771f163ecb46f9cbe7e48e999933d417af351aedaba" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-SemiCondensedThin.ttf", + "size": 110512, + "sha256": "50c79f3f05563cc9a18f5169d1304c9bd8207bd332528d264cd66cc02235fea1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhmer-Thin.ttf", + "size": 110220, + "sha256": "c6133bdc71664cdf06b05faa0c6584242662057030e8f23692f6e425895e1040" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhojki-Regular.ttf", + "size": 70444, + "sha256": "4cdcae034430ea2b5695d39c1b1fcb8fac1d8c32ef7d66615248e72d41781367" + }, + { + "path": "/usr/share/fonts/noto/NotoSansKhudawadi-Regular.ttf", + "size": 18684, + "sha256": "9efe023882dd36fbf7d838a7c5ec30e1b22a44f9733af8209dc7b8b30fdd0711" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-Black.ttf", + "size": 37344, + "sha256": "5af0ecf5bc7a3cdfc293ebf4ef044425fc907f77d7bc6413535ae527b9415cb9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-Bold.ttf", + "size": 37252, + "sha256": "18e3bf1443320fa807f9b85a9971d2bc3efe28b41bb96f75fde2096acb316709" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-Condensed.ttf", + "size": 35824, + "sha256": "78f9381e86c8916db60fb7cbdf78065398faf71c0c5f7494c582516f6db4eb0e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-CondensedBlack.ttf", + "size": 36620, + "sha256": "577d0c48fe165bec0b367a7df968f9037b864198b15c113c0595241eb1435b0b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-CondensedBold.ttf", + "size": 36688, + "sha256": "5116fabe482089a9bc1844809a1ce2e1e0cac36ec0ef75645426dbcf684bce98" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-CondensedExtraBold.ttf", + "size": 36968, + "sha256": "b1390bdfcc1e307a0072ac65f26c1b6113e5a4df684eff3ef8c6f5d216886f4e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-CondensedExtraLight.ttf", + "size": 34224, + "sha256": "1cafccf9579ca6e28f3ac99a42378c26f76f04b74964056cc1d88fcf37dd2fa2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-CondensedLight.ttf", + "size": 34060, + "sha256": "dabceb52823744a9dc4afbf52a630a0002d848f7adf9afe550991869614891b2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-CondensedMedium.ttf", + "size": 36008, + "sha256": "e9bd93feab6121ab044970df6e83411b3ceaa3aa5e3787cea98b87ac5087af46" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-CondensedSemiBold.ttf", + "size": 35976, + "sha256": "138573fe5f6d3fda2b58ec86ddfd952e908b6c02895f76d625d78847e32232c0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-CondensedThin.ttf", + "size": 34084, + "sha256": "f15bdf0f272d1d785f5e1a29102fec956d958bc1c31bb89bfc9064687a95c61f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-ExtraBold.ttf", + "size": 37100, + "sha256": "566277d4f6b81494a7dee5cf62e8a4989c223bcfd2142d9c04dc2f3eca2733ea" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-ExtraCondensed.ttf", + "size": 35808, + "sha256": "05efd39950bb3b7ffd86c23d58977c2187addf2dee34fa5b1bdfe6e08f6ad2c0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-ExtraCondensedBlack.ttf", + "size": 36600, + "sha256": "d0144675975d08e15887ce2c05defad874e1fa4c8546febf94932e28c89c3dcd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-ExtraCondensedBold.ttf", + "size": 36752, + "sha256": "cee25b48626bb1a6d75d6c75cc4af11643ac75a2b36fc376202220060ae8fe6e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-ExtraCondensedExtraBold.ttf", + "size": 36244, + "sha256": "01eb88171ca9f344ce85284943f3bbd6eed2b5c453b881a2ab4022ff2439c203" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-ExtraCondensedExtraLight.ttf", + "size": 34064, + "sha256": "32f106710113d1ebb1712c75a46a64f883473302c1e50e42222cb937082bd53f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-ExtraCondensedLight.ttf", + "size": 33892, + "sha256": "a611881834445959f9b7160614a695929d4a8d0cd5face24ae254a43704d15c0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-ExtraCondensedMedium.ttf", + "size": 36068, + "sha256": "2f4cbf184c97deda9b6fc9702c463a43aa962519a18c14f7ca80b2279022f5c3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-ExtraCondensedSemiBold.ttf", + "size": 36312, + "sha256": "0b1a8f600a5887f2c186695ed5c6b6ae1a67caea9863947a141d62567ad3e150" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-ExtraCondensedThin.ttf", + "size": 33920, + "sha256": "9bfe462ac5fbbdc56d95aa6a431b4e7c5cd326fbf68628f24479899e1a95776d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-ExtraLight.ttf", + "size": 34748, + "sha256": "ca2d8ecc126492ade5cf11e44d02f4bc2ddfba3a7abd270406e830a4826e2518" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-Light.ttf", + "size": 34288, + "sha256": "401639abb3d800a224505c616d45f977ba7d9793855fe7853817cd00e84c7b95" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-Medium.ttf", + "size": 36116, + "sha256": "0f2d921e4b7e3f0c3751c98d25e846feea5d2a4660c27b17aefb09663f08b6cd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-Regular.ttf", + "size": 35884, + "sha256": "0a86e5e1ccfe34ca78c43fac6829dc751b42bcc469272a9a55325aae587bfbe7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-SemiBold.ttf", + "size": 36584, + "sha256": "354558517ce01c2585862742ce150467ef4e1062c1e77d309b94cdaf03a451f8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-SemiCondensed.ttf", + "size": 35772, + "sha256": "4451fa9008c5468cebc77523911235718815a51903c9dadcba0ca12ea82d40fb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-SemiCondensedBlack.ttf", + "size": 37392, + "sha256": "c43040b8d33f2ac0a7e7c0ac1c29a7bf117a7bbbbd2b15ec87a168a42d940519" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-SemiCondensedBold.ttf", + "size": 37268, + "sha256": "37ffbdb3285266fd3a0b2bf75e6d279715bd9c5b1ab4645b93e434978e44bd96" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-SemiCondensedExtraBold.ttf", + "size": 37516, + "sha256": "83c8fbe70f37b5a77eb9e1fbb5499cb148a80a1678da4516c923549e4c3fc11e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-SemiCondensedExtraLight.ttf", + "size": 34572, + "sha256": "bfc9fda9c0596cd2b4cd131b18458099b49fc7cd45a97dbe266767b8c16ad250" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-SemiCondensedLight.ttf", + "size": 34432, + "sha256": "6c76aeae1cb71801ba8928ec36e662e4246895aecc780276d6d5cad2363c8159" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-SemiCondensedMedium.ttf", + "size": 36244, + "sha256": "e8cda5657a6ed22b46eb58ce062eb82253de00c5e2c5bbe9752d8edc5de01bae" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-SemiCondensedSemiBold.ttf", + "size": 36288, + "sha256": "d0bf937bc04f7eca828697d4c71e07a08560aa8410d3f6fe5506c4270f25b33d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-SemiCondensedThin.ttf", + "size": 34280, + "sha256": "20bedec69688636e3c8ee5dae61568e835f55cf5fb492ca815ffda2c21149e95" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLao-Thin.ttf", + "size": 34256, + "sha256": "d345d8b1012d95f43d5e70e3875a3623b8be152324601747d692015165937c17" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-Black.ttf", + "size": 57348, + "sha256": "c8eea48c20a30a255c12e5674d505b99552584845c0b55d8ac46c52ef59cce5f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-Bold.ttf", + "size": 56240, + "sha256": "b686a828bb3eac6cf426c4673666d79447142fb7841d870ed37c68a7608965d6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-Condensed.ttf", + "size": 54916, + "sha256": "5f3488397433083ff09c2ed03e159e4d6d26235887ba861a0c39176e0cc751aa" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-CondensedBlack.ttf", + "size": 56056, + "sha256": "2518f20c7cad853888a43268bd9b7524b610418b21cb6f96071bb9258c056ead" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-CondensedBold.ttf", + "size": 55276, + "sha256": "f152c09029361a65187c818a8e7af8ba9c267b3c59dfe0166113f5f887ff6c99" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-CondensedExtraBold.ttf", + "size": 56208, + "sha256": "613659d992ce646252af6eb6f8f45ef2403b1d42e92cc0ffadf9ebfe4a8fa485" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-CondensedExtraLight.ttf", + "size": 54864, + "sha256": "ffeb65d8b33e0c52ef4187ea8e0a88a9c8f1bf4e955334cb7147026791a839f4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-CondensedLight.ttf", + "size": 53688, + "sha256": "dac52b978ce971e8c3d4d1c6743d909cd2705053d9b2b429d2a78cfc2b5c8d5b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-CondensedMedium.ttf", + "size": 55140, + "sha256": "69e13fa0c4d8de17db02f2b33c8acad338c534acba926c7ef9a7bce81e88eeaf" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-CondensedSemiBold.ttf", + "size": 56276, + "sha256": "a60c86bd07e82b430dd5d061aa43cdede7771cbd8d37482ce7a1de703eb5aea6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-CondensedThin.ttf", + "size": 52260, + "sha256": "83e8e368d9cf2c97aa38cdb8c3693cdad899d5498f9cd422e12fa8a7786858a9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-ExtraBold.ttf", + "size": 56904, + "sha256": "5ce7d7cdc033b394bb5f322b2496227c22d18cc6ad03f5e47c13a53e73a5b010" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-ExtraCondensed.ttf", + "size": 54376, + "sha256": "aa9cd08630fb4b427a657fa4e4413f19496f92efc16d69741c7940a1dba8402f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-ExtraCondensedBlack.ttf", + "size": 55144, + "sha256": "8efd129abd0746eda3da666f26f1af045ddf08989ca88085cc9526befe79d356" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-ExtraCondensedBold.ttf", + "size": 55052, + "sha256": "4a520da024f5d46e6fd2a9ce55703252dd62802da628f12c7203885682c184ea" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-ExtraCondensedExtraBold.ttf", + "size": 55664, + "sha256": "93ddde4d94a50c7d0167512c11c5e538fedcf498e9104f62fdb9114981776afc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-ExtraCondensedExtraLight.ttf", + "size": 54244, + "sha256": "232a76983c971c7de9172b6b29c1b2521741bc61c939782ed76c58893f2b75c0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-ExtraCondensedLight.ttf", + "size": 53496, + "sha256": "46c45a2ce882902a497b78e3f8229042d034cdadb782ed4766941024e3ac93a4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-ExtraCondensedMedium.ttf", + "size": 55064, + "sha256": "9c28911db4c3f45471e2aeb4f976917c2212292cdf45252a625fc83f3bb7c76b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-ExtraCondensedSemiBold.ttf", + "size": 55756, + "sha256": "e44b4a6c98c5829c6c799fbb3b3fab4e6ef8447e9dd84d4a3b7f321e521210e1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-ExtraCondensedThin.ttf", + "size": 52452, + "sha256": "ddc358d9ac7141e47ca338c93e11b7dd468db7a61ed0360c2f9ce226ee01cdd1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-ExtraLight.ttf", + "size": 57668, + "sha256": "e66bf9e1fddb8cfd72cd475595b97f955fed441eb66e79627b367b6475b12282" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-Light.ttf", + "size": 54032, + "sha256": "b5d0b82ee5312736fc0682efe1dbf523c51c969ea6049bfbfaca961d21b4d1a6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-Medium.ttf", + "size": 56516, + "sha256": "b9bc6e506c4fff7bbd92a14ea7c9b0e4564059bf2d68836ca6ce7e750d048bde" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-Regular.ttf", + "size": 56300, + "sha256": "fb41283aee26c91b93f3df6c11229054bf64407c494c4ee874b536b1330a06c8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-SemiBold.ttf", + "size": 55664, + "sha256": "5d10b4edd275e39cebb17b93fdfbfa73593e87783c31720a935c730b37205a49" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-SemiCondensed.ttf", + "size": 56560, + "sha256": "65fbf23f8fe9530640baaf09de70b854beb1056b691eab41f2582803f7528c0c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-SemiCondensedBlack.ttf", + "size": 56480, + "sha256": "24368a9cddf5cc8efb7ad1f9af603e7450539e31575b7c0638b7e135ee6726c6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-SemiCondensedBold.ttf", + "size": 55548, + "sha256": "1e3588385c147f001b0d6622bca76fc3cd7e8abe9ee858781be4dd84231f737c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-SemiCondensedExtraBold.ttf", + "size": 56408, + "sha256": "b46acbf3771cb21b9771553fb20dd5e69271f227a8a240b01cd400567a9ca96b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-SemiCondensedExtraLight.ttf", + "size": 55696, + "sha256": "7c240ef8fef5e47f9a09149a1b7a87d64986ee4e37b67529b5d1158f108552ce" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-SemiCondensedLight.ttf", + "size": 53756, + "sha256": "8a03fcea79783becb72462bb1f0d69f2128a06c5ca855e0bacee8c210fba3e71" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-SemiCondensedMedium.ttf", + "size": 55804, + "sha256": "077f44f04ecdec5c53792fa372e03e1f6f814d4b245c3f105700420877355a64" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-SemiCondensedSemiBold.ttf", + "size": 55632, + "sha256": "b8293e8cfabb88d2f573a445117987b43ea0ee4fb4259d75d8966825f1df8cb2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-SemiCondensedThin.ttf", + "size": 53564, + "sha256": "6d35ae0746003f30e18528cbe15a8df27eb8a3b0fff0b46ba2ed2dba10b3567b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLaoLooped-Thin.ttf", + "size": 53508, + "sha256": "cc132dd25a3bf1abb9f09c8b87e4bb79b4cbf67079511d9626c88b360a21ddab" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLepcha-Regular.ttf", + "size": 26084, + "sha256": "8dad9629d709f0dba5d769988d1466daf300751f6bba2659465aaa6214261590" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLimbu-Regular.ttf", + "size": 12804, + "sha256": "5cea5d0f2a089c36ea24e8481f65a2aff5d4b0890db440326cea2c808f82d582" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLinearA-Regular.ttf", + "size": 57880, + "sha256": "af2b1fecbe3e1e96cbd0e22fb514512411e57753c7fef477546d7232eb8211a4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLinearB-Regular.ttf", + "size": 62596, + "sha256": "940af117332cd4ff54fbc19553399be9228aff0da370a7ed36baa18fe1a9f23f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLisu-Bold.ttf", + "size": 12704, + "sha256": "70b29be31b852a68793a3df61be8c38a9b6c2ac586a1ad66821d7030c9541de5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLisu-Medium.ttf", + "size": 12708, + "sha256": "80c68af15da027ff776f5be4b5a58cabd48bdf3e32f8c7e2219fd66cae62395b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLisu-Regular.ttf", + "size": 12696, + "sha256": "f56f6bca7618c5d95d9e2adf45168844f161dedd95abeadbd58c1fbba7be750d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLisu-SemiBold.ttf", + "size": 12776, + "sha256": "ff7788d5d1e94e255b4bf15a4b925c53d2bd5e6d70fb741f5508ad87e7781b28" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLycian-Regular.ttf", + "size": 4200, + "sha256": "2c580772ad04d8ba4eb63e16432615a43762ca59d7b59b43dec25b239ca38986" + }, + { + "path": "/usr/share/fonts/noto/NotoSansLydian-Regular.ttf", + "size": 4680, + "sha256": "6dbc48fbf04ae04021c78273492dbab284eb8ffc2cd12fd3d0b61813b123162b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMahajani-Regular.ttf", + "size": 22376, + "sha256": "82116df1a82007b3a3692b9ab52683ccdc134d37d88b1e38921b07541b5afe08" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-Black.ttf", + "size": 118572, + "sha256": "bb6cf10cc0fe0b3053352afecfa297d7c2b2d5e9055d918852f44fb11e59cf7c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-Bold.ttf", + "size": 118480, + "sha256": "d5c52d4fdf019feb21cdb748573308fa5fdd94a81d5bc71c603c076207039590" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-Condensed.ttf", + "size": 133424, + "sha256": "ec2de660d410f622b34a8f53cac226be6ef7eacf0eb1d14d5e2f214a62e37ba3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-CondensedBlack.ttf", + "size": 117692, + "sha256": "c872142fc0a48776d94a9760052533bda86f38cb53078a7a30ee783a303e8124" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-CondensedBold.ttf", + "size": 117408, + "sha256": "6c70f8911fd039dd30cd3c0109e721afe3f7276f2e674ba54dae87abc87cf069" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-CondensedExtraBold.ttf", + "size": 116776, + "sha256": "49195d47f29665f2e8bf98a8ee376360ac77a637999c1543c8ac6fc917506021" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-CondensedExtraLight.ttf", + "size": 102992, + "sha256": "4c5effc3ba7a04d29467603a6336bcd3068e8aab3aabd33598a2c1ba443d7cad" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-CondensedLight.ttf", + "size": 103808, + "sha256": "ec11c6a9d4965fa5f56c1f2d32310edf9098d56d458fa3e64c076655975aac29" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-CondensedMedium.ttf", + "size": 112684, + "sha256": "f09fc163ea805d210ecf50aae8e5f6c7bf2f22fcc772e660905bb1ddd4b4c77f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-CondensedSemiBold.ttf", + "size": 114676, + "sha256": "5def8442b32b47c6f56a6091b55c441cdbe07768fe3b3d725cfcf97381bd1719" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-CondensedThin.ttf", + "size": 105540, + "sha256": "905213dcd0e8f0ca6cfc42d5cf71d7f6244744bea4ba4401ada79028413c28ae" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-ExtraBold.ttf", + "size": 117272, + "sha256": "6f52cba072a1b7568eacc1c79411def741b61ed702c8e968e87341e72ed8d55b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-ExtraCondensed.ttf", + "size": 133196, + "sha256": "1b0bb294e9f381385e1426ad57264ac96fc92133f297a1fed790d099d168adf7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-ExtraCondensedBlack.ttf", + "size": 116884, + "sha256": "b699f5e8d87ebe31758d1ad9f8e05da54bb259f5194f09608792209735c2c951" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-ExtraCondensedBold.ttf", + "size": 116020, + "sha256": "5b35f189d3061654047488e91a4042be0e6be3c9ccaf18e58cf96c021c6ca576" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-ExtraCondensedExtraBold.ttf", + "size": 116748, + "sha256": "978463b351c7ab24b0f992af6487e178da51326d713f275d9a9d3c4b9b6cd23e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-ExtraCondensedExtraLight.ttf", + "size": 103776, + "sha256": "6a80f6e49f1150a94e7540bbff24095e5524242e142a56dbe04d9afeba0979e3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-ExtraCondensedLight.ttf", + "size": 103640, + "sha256": "36768e68d099b45de216ed442bba9328c533fd561c06770525daefd0dfe669a3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-ExtraCondensedMedium.ttf", + "size": 111816, + "sha256": "fd18c34e3ac82421558d94f20da671158f748538c96384adfa44baa19f66f3cb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-ExtraCondensedSemiBold.ttf", + "size": 114244, + "sha256": "e2525f9df80e603a383f16136138105ebebc6db9b30a831c98213984cc1055d2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-ExtraCondensedThin.ttf", + "size": 107520, + "sha256": "d8570f3a9ebd9734d1b1d4d1e31348a5f33916d32727fa8b6079ab05ce02cc0c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-ExtraLight.ttf", + "size": 105168, + "sha256": "b010b307261f40deebed76909e1585ef5c02b94de8f284200288055172052914" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-Light.ttf", + "size": 106412, + "sha256": "3cc182a9e155ccb1758b4b113af9be1434041fb9a8efd9128a4388b371c4b925" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-Medium.ttf", + "size": 114532, + "sha256": "2914e284db8c7b77973e9b941eb12560d20b59c465a586ad0ba5649a46c9caa3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-Regular.ttf", + "size": 112936, + "sha256": "c08de7fa8d032a5d6a4d120fb82c78cec60b362a4e73fa26360d89759ff2a7f9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-SemiBold.ttf", + "size": 116448, + "sha256": "707a349aa9340fa87911b5af43713857c4390c50d1e84f4bd05c3ae5dc015f5e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-SemiCondensed.ttf", + "size": 112612, + "sha256": "185924c677e71574757256150ff041ccb7a3ac3b1a377514dacb705a10be8214" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-SemiCondensedBlack.ttf", + "size": 118484, + "sha256": "cdf1ec7a6f1c3e4b2f857615e8f269fbc9281e6a1e173464399b9e10ca517165" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-SemiCondensedBold.ttf", + "size": 118372, + "sha256": "5103011efa3244ac7a93f14179ab1877fed6b06e9110112412e37543264a81b8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-SemiCondensedExtraBold.ttf", + "size": 116896, + "sha256": "98ded13ba168c1451924901bcc8f9ee27b6ca8af0a356fd7e10f82599582366c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-SemiCondensedExtraLight.ttf", + "size": 104240, + "sha256": "c59043f93bba19eca844ba9797433aef97fd7ef993f0e5b6ec9271e8df07a56e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-SemiCondensedLight.ttf", + "size": 104824, + "sha256": "b978ceeba45ea20d897f0082d7b1631b0524bcda8ec16008209d750257e77070" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-SemiCondensedMedium.ttf", + "size": 113936, + "sha256": "f615927a5292839120a347cf0b08c1b9ea1d40af03657bbf941072263793220c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-SemiCondensedSemiBold.ttf", + "size": 116260, + "sha256": "de988b1443a095fea04c369f88e072057bf4c3cb21d4d8067b31e0c21362d41f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-SemiCondensedThin.ttf", + "size": 104696, + "sha256": "4ce15eab0184024f8e33650fa5a3f948f83059db7456b601a23aa71a2427bfc4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalam-Thin.ttf", + "size": 105640, + "sha256": "2c52ce6e47517a5ad817f26d1819cdd56dd8e83fa5b83e7311e6b7c285fa59a8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-Black.ttf", + "size": 118392, + "sha256": "73183c09f808c0fbd5b410fc42447b3a5c78da18a99068cfc8cc17facf61fd62" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-Bold.ttf", + "size": 118264, + "sha256": "c4ca0777b23139262a1f77e608a68c541db082be7f0405e8a449bbb812dedb2d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-Condensed.ttf", + "size": 133680, + "sha256": "4dd1e1ba3636a34f1fa77a63150af54f48b4ddca2ea48a5db97a7e122b9c6d63" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-CondensedBlack.ttf", + "size": 117508, + "sha256": "b1e72270de8252809763a15ee4f9ecc85a9504118cf5f429fa6c24e7c10b0554" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-CondensedBold.ttf", + "size": 117192, + "sha256": "d777d11e5df4e34f3207b4f132e7c2e3edc65ef19e01e4a9dd90f96db774b493" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-CondensedExtraBold.ttf", + "size": 116376, + "sha256": "43342bf6e27772bc7e2e908e2a2d1433d073716124b0c310ffe9d31e94f92b2a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-CondensedExtraLight.ttf", + "size": 103068, + "sha256": "0220bdd22c166cdbd9e71cb101c68290c4da6084ec71f6e37628cb8834b6ca97" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-CondensedLight.ttf", + "size": 103896, + "sha256": "5ea0d2359e920b6adbb0cfa5b9393d995894d951906f63ddd2b2a061821d45e2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-CondensedMedium.ttf", + "size": 112856, + "sha256": "9cc4b5ca573b4a5f57bb927f9c0c846032d65144f09d2b75a73b8bd371214277" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-CondensedSemiBold.ttf", + "size": 114456, + "sha256": "fbdb5ad454498f988e7fb82adba3d87109c2eb36e792348159cd3ac7d0deb75f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-CondensedThin.ttf", + "size": 105800, + "sha256": "d77ca8f50fec60f64cdedf15d8b7ba97410b74823c280dc8e8f81de846d3c5f6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-ExtraBold.ttf", + "size": 117052, + "sha256": "e8a3ae936f5156722974859ab37f7671d38f75d96787d568aa86292c5dc8b54c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-ExtraCondensed.ttf", + "size": 133444, + "sha256": "c940257bed4759a1d0fb6fe945c68b215bd9783a3a92a61b432fee685f1fdf4e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-ExtraCondensedBlack.ttf", + "size": 116700, + "sha256": "4786280a902b95eace1950a728a4ede3cdedc926d432603c5fc53d6681bf1738" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-ExtraCondensedBold.ttf", + "size": 115812, + "sha256": "5efca74dcb7f40dd6feefab223eb2d0d7781223ae986963e8f530b788f90cbee" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-ExtraCondensedExtraBold.ttf", + "size": 116552, + "sha256": "33a6d6fd16ca0c77f271a6b3685fec5d35ac87e52ede2846aa968bbb3375d43d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-ExtraCondensedExtraLight.ttf", + "size": 103864, + "sha256": "60167376b8635f526c3f7f44d751253414227f3aaa61185ef0a3477a1c352149" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-ExtraCondensedLight.ttf", + "size": 103972, + "sha256": "c57d0f8d9241dde718e2618598d46b4881c42c55389df6cfad007af13b3cfb07" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-ExtraCondensedMedium.ttf", + "size": 111884, + "sha256": "38a9cb1d5c4e69fa885f7213135d804a017a62946192d2c54cf2d7bc9e2b80f5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-ExtraCondensedSemiBold.ttf", + "size": 114036, + "sha256": "256fa87a9b41679df9080f65060926cd235e7b56919d8e72032438ca4401311f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-ExtraCondensedThin.ttf", + "size": 107776, + "sha256": "47dfe80b1a4214765fdb201f60af2021d00b1fecdc2264aa65fb3328a8e840ef" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-ExtraLight.ttf", + "size": 105244, + "sha256": "5c811532798e2b74a66873c8a5a88a7ce9101f9a674b689e69353cc7d3820cf1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-Light.ttf", + "size": 106488, + "sha256": "7042b8230cf2c773be531ee3bdfee67638afce30a8d283eddf878b7da53ce8ce" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-Medium.ttf", + "size": 114692, + "sha256": "15ce7a4157790c6a8318ac04c8829502ea478953db108a5c9aab3c27fbe6a8c9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-Regular.ttf", + "size": 113092, + "sha256": "035011757d268479cb5119b8ab0f5be3c4d0a0454b04aba0268010faba5b6279" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-SemiBold.ttf", + "size": 116228, + "sha256": "4bd400b3ecb638d1b573c05acb76305b86f71b92ed6cf44a9fb84d6631177707" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-SemiCondensed.ttf", + "size": 112772, + "sha256": "473e2d7b1532332ee41ae05388d45bd47f5bd2d8071615384ac2f1ae7daec593" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-SemiCondensedBlack.ttf", + "size": 118296, + "sha256": "bd612545a5e0406e471ceadb0f492af8c699892b742f007d569632414b91752b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-SemiCondensedBold.ttf", + "size": 118156, + "sha256": "547a77523ac3edadd4251f78f6fa00d9cbc41fc106d24a23a4928c451d92fb8e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-SemiCondensedExtraBold.ttf", + "size": 116684, + "sha256": "6d166fce3cd23b4db143a2c399c51ee60f15ee2cee216ef471991cf1ac2dc7b7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-SemiCondensedExtraLight.ttf", + "size": 104324, + "sha256": "34c9d1e307608bcc60e6c575457d5b092eba0d156dde2482f66219fdacc0b699" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-SemiCondensedLight.ttf", + "size": 104904, + "sha256": "9752e97f5f26132ba52b68ff5c0121d7fa790d8113e5384034de8e40060118df" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-SemiCondensedMedium.ttf", + "size": 114092, + "sha256": "1ebf269ce27cdb3dd821ec078e6ca3df15f55d2465d95e4b143b86e87f38b589" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-SemiCondensedSemiBold.ttf", + "size": 115992, + "sha256": "2ae8e70e25d7b5a375483fbfd0f8fcdfd091ad515a38febf05a8a5a5e9200c78" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-SemiCondensedThin.ttf", + "size": 104980, + "sha256": "67ce806d88e608de385488496787f810648e6121d7f3dbb4ab3dce797b31993f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMalayalamUI-Thin.ttf", + "size": 105904, + "sha256": "1cce5f1ec9f8e561c65ad62d0ffffc0eadba8eb99b0d5d2a1cf0afab4dd496c8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMandaic-Regular.ttf", + "size": 29328, + "sha256": "0ecb2440205b9fe02fce714ddda98bb59892d4f7eacce2c1f12028b172a34de8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansManichaean-Regular.ttf", + "size": 51996, + "sha256": "29b281b63ca0d2478017a72abcb6c0b44d36af52a0e4eec2b5622be7c3a9442f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMarchen-Regular.ttf", + "size": 127388, + "sha256": "fb40b1294e8f7f4e93f9276b2c3d2972a2643511394eb3db806e0e257b62b893" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMasaramGondi-Regular.ttf", + "size": 30628, + "sha256": "da26087eb956e722a316a531a42f9f1ee8f8b60bf1a313c5763d71361c729c80" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMath-Regular.ttf", + "size": 990564, + "sha256": "d51afd5739c7ba6c44fcab35a88160e25dfb69a2d4ad0bd99533f8d894af1f96" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMayanNumerals-Regular.ttf", + "size": 4444, + "sha256": "75287bee334c367b26386b54552a788c18b7a52fdf0e054302afc6f75210c8fb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMedefaidrin-Bold.ttf", + "size": 44492, + "sha256": "45497e7f512b2789ecc1b0df4db4daf030e9cc877fac7f5430df560ef668d25c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMedefaidrin-Medium.ttf", + "size": 42880, + "sha256": "9dbaf2a52ba9c4e5f3f718391a9b7d3bf83e83f098c86e06b7d8f18a36a6496f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMedefaidrin-Regular.ttf", + "size": 43112, + "sha256": "a91d91b2a94d480b6f95ebd64b66d21705f1823fd83c11a927848d979c776586" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMedefaidrin-SemiBold.ttf", + "size": 44048, + "sha256": "5976cf7a2752ac9da7d2326eb14ef346987b07443fd469ea02adebfadc70310c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMeeteiMayek-Black.ttf", + "size": 16344, + "sha256": "930b893f9754a7f5d32ec4c7e32c1ccf6414fe958cae5b1bd95bb12c980697bf" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMeeteiMayek-Bold.ttf", + "size": 16336, + "sha256": "9f3a887f784cc4faeb9ec32e9e48c06a12b79315a1672c9b136260e0793aa3c3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMeeteiMayek-ExtraBold.ttf", + "size": 16388, + "sha256": "28efb67190c4eba5e8acbaa3e9619ace5279978b6118360c5483128432917bf3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMeeteiMayek-ExtraLight.ttf", + "size": 16624, + "sha256": "d1686e0ececeefcbfda75ada6651a4d4329082e681122deaf832f87a2636e103" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMeeteiMayek-Light.ttf", + "size": 16644, + "sha256": "cb6393845ed69c53f15864bf125843f15bfd284ca248ee5c083ca452ea9fc498" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMeeteiMayek-Medium.ttf", + "size": 16520, + "sha256": "8494c677ceb61625fbcf19af9033261c4cce21ad7d50f2537ccd200a0cc1dbff" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMeeteiMayek-Regular.ttf", + "size": 16584, + "sha256": "66dd434ad6211990d95ba598ad8e44dee30fb32556cb2310bc7f246e00c40990" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMeeteiMayek-SemiBold.ttf", + "size": 16440, + "sha256": "2e2d17606eb09e4ba4cc156d23e3837d29edc5bc69ffc2fcee842dbfd9baf637" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMeeteiMayek-Thin.ttf", + "size": 16628, + "sha256": "aeae17124af801dfbd774582ffbeee70c0d7126c4d31570bd9458bb31840a231" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMendeKikakui-Regular.ttf", + "size": 35196, + "sha256": "89496328e57ae33930d147127caf57edb3f23defa835f73ac3f0b1856e16a073" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMeroitic-Regular.ttf", + "size": 33384, + "sha256": "2baf503fa5c041fd025493de311e45b8680c9f0bb471a8a2418aa4e47a4b7edb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMiao-Regular.ttf", + "size": 36424, + "sha256": "a172b5da5bf602042cb526172ecb279085c906f23d901d882cf6938909f7e574" + }, + { + "path": "/usr/share/fonts/noto/NotoSansModi-Regular.ttf", + "size": 44220, + "sha256": "7bebf7f16e7f98695e1003adaa3b1438ef977803aa166fa7d5e6b18c9dfa69b8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMongolian-Regular.ttf", + "size": 344828, + "sha256": "a28ba3cde3de22de7ddc934bd5d5babe54e6ce28c073a288cd978ffcf26b295b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-Black.ttf", + "size": 622272, + "sha256": "f2a28df76a1ff7d43648d2625c895468ba3ca2910e0c3ce133361225c0064cb5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-Bold.ttf", + "size": 610908, + "sha256": "a21ea0ba6ea49fda7b34ca39a504b487f1130885d36e1a4f9f4255b3ba6994bc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-Condensed.ttf", + "size": 596712, + "sha256": "d60154ae4ecc9f2e8a1333de367b5decff30715f50dd1eca0048cd68fd905fd6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-CondensedBlack.ttf", + "size": 612200, + "sha256": "caab5e9f42413742e8e672f0423aa20ab3d9a7b4da3c4886a2b7238b5b711341" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-CondensedBold.ttf", + "size": 602716, + "sha256": "0e00e373e793790415da3c43d8a81fe889bd5c6306e2954e13a0b6052a2e59e1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-CondensedExtraBold.ttf", + "size": 607724, + "sha256": "031a5c9b7a5ef7b3d0881bc06fb17de2c856646fc36a58597363d842a920552e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-CondensedExtraLight.ttf", + "size": 579544, + "sha256": "72f276ecce1d80f5d689ce630191f4216138abc6e7f88d115fe782eb287ab6f7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-CondensedLight.ttf", + "size": 583536, + "sha256": "7b5460d4f41df8b91c833883213ec7641126f7952d00b1383008251f1219e960" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-CondensedMedium.ttf", + "size": 598264, + "sha256": "4724bfefed6d7ececaec65af6793ec588231b4b83657b7871bab96c1d8d0bb35" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-CondensedSemiBold.ttf", + "size": 602124, + "sha256": "57ed75368020dfe638c702822a5d5d721b937666f04cdb37cf6bcef14bda299d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-CondensedThin.ttf", + "size": 584744, + "sha256": "68283375c780515dfbec91131e5ca6e969953aad80c3dab4f063dfce6f7545ca" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-ExtraBold.ttf", + "size": 613220, + "sha256": "fc9d9533b8b8e8dff7c8f86a959251971ff97c2db416c14077fa7b22614bcb4d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-ExtraCondensed.ttf", + "size": 595484, + "sha256": "3530253a397fdcf8156f1961de96081e93f7492f334b6381f8135ab1b7bf44be" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-ExtraCondensedBlack.ttf", + "size": 608480, + "sha256": "5b45eb7fba4e9e8930d4f2ead3989e1030e44a04fd2b544caf7d86597f806c0e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-ExtraCondensedBold.ttf", + "size": 603408, + "sha256": "5bc8447ec511ab0a1dd8498adadd1cbef1d70e94c625fb614eac40eef30730d2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-ExtraCondensedExtraBold.ttf", + "size": 604968, + "sha256": "54d4f8c63e9af12c96b25995cb5215f2891e51432e4096b68225122c2df2720a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-ExtraCondensedExtraLight.ttf", + "size": 579408, + "sha256": "829257ff6cc7c86eae6de69f42c970023525229e56079314ef6bbbc8057046fd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-ExtraCondensedLight.ttf", + "size": 577992, + "sha256": "65493bb96369db92ebe5f3836b8a1bf55d7423cab60312e08b59915a897bf15b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-ExtraCondensedMedium.ttf", + "size": 595540, + "sha256": "c20218853d3593824f1b71003220a3562c227877c4cb5c4ddc41f1f9de575151" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-ExtraCondensedSemiBold.ttf", + "size": 598164, + "sha256": "32fec82e43ac237c0abcc6eb0b8506caae7b8b4fdf1e086580226712c7e89130" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-ExtraCondensedThin.ttf", + "size": 583660, + "sha256": "073fb8b95f0552dec849fae998ed05ec7bb848c0c465c6e20b94f9ba846c9538" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-ExtraLight.ttf", + "size": 579992, + "sha256": "d4071d53c60f4d1022dc07e9d6b9df693798fa28a802d1451d4e053e2e6af1d8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-Light.ttf", + "size": 585580, + "sha256": "9c9dc46df01c3acee14690fc1be60f6c863675b5ac9ee190f304656dbb9ba9aa" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-Medium.ttf", + "size": 597720, + "sha256": "53e262499b3e6b4e7274dcc4840e9374c8d9c4a4fad48227a38d8cedd2e34c40" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-Regular.ttf", + "size": 596428, + "sha256": "65b5e2b2c4a1fba9ae8be1f026cb35b03dcb8886d9b2a4147054fde12f7e767d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-SemiBold.ttf", + "size": 605436, + "sha256": "82e49094ec3a1a3c8fa0d730016f5f890384f6e164dd2cc84f39b30639bb6e8f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-SemiCondensed.ttf", + "size": 596404, + "sha256": "88ec249753ac493942982c327c4e2242404154b63e8e5e4324767742bacd0d72" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-SemiCondensedBlack.ttf", + "size": 618300, + "sha256": "18ceccd203e8000506c239d8934aca48c7e93798d18d5559bc466fc36dfd2228" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-SemiCondensedBold.ttf", + "size": 603448, + "sha256": "92f07da3378d4e76420ccf3450c0b38dd7173e45992ab4d35676e5edba16e1f0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-SemiCondensedExtraBold.ttf", + "size": 605436, + "sha256": "7004e5b26378740d5918abf957dd6d3f7a533bd9812389929a2ef74ea35e9fcb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-SemiCondensedExtraLight.ttf", + "size": 580836, + "sha256": "1e1329f10087fe60ba06858f722978138683431c54207f43daa2300211a005cb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-SemiCondensedLight.ttf", + "size": 585316, + "sha256": "28e39c4bee447560b5d14a58d7da76fba7450d1908163e71cc2e8340f3601acc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-SemiCondensedMedium.ttf", + "size": 597036, + "sha256": "fd49b539fe83f86e1aa27dddb51d2da8ba2a0e987a3b84069834302d2924b9a2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-SemiCondensedSemiBold.ttf", + "size": 602284, + "sha256": "3c3c26a991ded3f49123d61efe002152f487c688426cbeae82dd0cb524ef1d72" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-SemiCondensedThin.ttf", + "size": 583640, + "sha256": "74d7a9f66a53409f8b4e1ff85dd01354a3ff8e733da05de56647cdc34b8ca23d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMono-Thin.ttf", + "size": 584136, + "sha256": "7c430e89bd442c5d02f947ca190fc78cb54136fc3f32eca9a7a3c492820e7074" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMro-Regular.ttf", + "size": 6628, + "sha256": "2ab2149be222d9671ab2edcbe8736381e2d1550662a368b5087fd9e196bc69b4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMultani-Regular.ttf", + "size": 16496, + "sha256": "12c2437f98173dbff98fa18bcc73c6a44896634fb6d55d8f6d028394af4fc9b9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-Black.ttf", + "size": 222864, + "sha256": "42e8ce9e30184cc2241f25aeccbde93fdfb522ea749c3534907472a9308ab3f3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-Bold.ttf", + "size": 208760, + "sha256": "99a8c6768416747653232ded39292e789ca579f4272adfb2886f6aa219d20302" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-Condensed.ttf", + "size": 198628, + "sha256": "5857cffb0c127d13ded4c02cb603522147908d14e7b4c42329e153921f38049a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-CondensedBlack.ttf", + "size": 209252, + "sha256": "a72159915607be24360d0a553c767e86c41f5ee34cd42032d53c93e25799947d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-CondensedBold.ttf", + "size": 204044, + "sha256": "e6c78d8c792b5516051d3d40cd88596e15230d6dc03cfa7b0c8fe7c920beffdf" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-CondensedExtraBold.ttf", + "size": 206284, + "sha256": "cd706df037685164462b5a0895e1dccdc223900cec504a4872b4923715241098" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-CondensedExtraLight.ttf", + "size": 193736, + "sha256": "e5c2d72335af02f071ec468bce855e12e48144d3bc1cb3b251521b5b62ce8cc3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-CondensedLight.ttf", + "size": 191792, + "sha256": "cf79b22755b155f879134eb5eb320fb256844049f97e2fa9721f0377b7297dbc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-CondensedMedium.ttf", + "size": 196400, + "sha256": "2240339137e047e589f6bd3009230f0874d2af04f99733d6012b882dbf405d80" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-CondensedSemiBold.ttf", + "size": 197444, + "sha256": "a6cd6e886114cd4fd75439d3af6fa9496d07c602491711288f930c016d94923b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-CondensedThin.ttf", + "size": 195056, + "sha256": "a741046aad2b72afa2c16888d916ba4533322c1b913ebe55149d1e2a5ce8a643" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-ExtraBold.ttf", + "size": 211564, + "sha256": "7f89f3d0fcb08e88b62bc0b49cd36c3d1d41036317e2c23d1ea8f3deaf58d1ec" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-ExtraCondensed.ttf", + "size": 199196, + "sha256": "efd66ee2145b7c3d7f5e11d9b42a5d9d7908a59c86744c5f630b98a905c4d927" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-ExtraCondensedBlack.ttf", + "size": 208720, + "sha256": "3116227982a5d3a98e425c1d78306dd08e3725b6f6f68e69fb2dce64d5d46469" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-ExtraCondensedBold.ttf", + "size": 200492, + "sha256": "04982777f2483a192efcd7c68eed238cbd5c6b0390898e27e90cb4f960193bf0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-ExtraCondensedExtraBold.ttf", + "size": 205624, + "sha256": "b9306c675f394d3ac4d10f3d4dbdb0c41027e97bed2e3dade449d8ff4219cf73" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-ExtraCondensedExtraLight.ttf", + "size": 192612, + "sha256": "040a0e9b2271b04852326c00f91b2c0911a6d2f9e1a702e9b1c881563ff62665" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-ExtraCondensedLight.ttf", + "size": 192520, + "sha256": "acd10ab9572d29d6afc16f4c75575a8bb1ba358d47667671b8a44131dd7741a0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-ExtraCondensedMedium.ttf", + "size": 196364, + "sha256": "653fb83deaf982c3deac4d24df0e8702d42dd966ce26a7501cab0b41f27f0611" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-ExtraCondensedSemiBold.ttf", + "size": 198960, + "sha256": "059a5fb61bb3c31efed24bd7d6b532a18378121e92273badf353beb19d418b72" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-ExtraCondensedThin.ttf", + "size": 193976, + "sha256": "279cf36c10f4959ee9043b0435fae493aa7cf0ba3cfe55449328ca57f8262575" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-ExtraLight.ttf", + "size": 194976, + "sha256": "2de37c17523b1714790352057697e32b6b025a826143efa9f61ccbd39010a4df" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-Light.ttf", + "size": 194140, + "sha256": "c02c315a4993c7a31c4531a8e4ba4e44da1bf21ac2a3ea74ed5e418f7a0b8196" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-Medium.ttf", + "size": 197360, + "sha256": "7d6828dcd79091e31abc4e10750db707f1a19562b57b5f1188163a9872225b68" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-Regular.ttf", + "size": 195088, + "sha256": "fafce4db400bc0b214907ccdbfb0ad2f18a57bfefd08c8a571830b84088cf2fc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-SemiBold.ttf", + "size": 200188, + "sha256": "295f7853785f969547869202f634d8935cd857ad4c55f427a4ac3abda0adce87" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-SemiCondensed.ttf", + "size": 194416, + "sha256": "2f39125cbb5f3afada4396d8d9c19227d157535eb0df3afc1954fa707f809922" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-SemiCondensedBlack.ttf", + "size": 221500, + "sha256": "f678213275c09a17846b8fd1554be1043cdf9a2f1e5950f37ee27db1ec359435" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-SemiCondensedBold.ttf", + "size": 204816, + "sha256": "990f245d06a574c5cd47d5e2556f7594b3d60ef1137a8021773230c05bc88137" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-SemiCondensedExtraBold.ttf", + "size": 210728, + "sha256": "05c6e12c1f787a553c6c19bb66021b71c5d1f0d62d480f13f551e1d84a628e6d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-SemiCondensedExtraLight.ttf", + "size": 194396, + "sha256": "df4af64208522e5af7227e23c4dbc49b213f7bdca20bcd21713f1cc9281c047a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-SemiCondensedLight.ttf", + "size": 192580, + "sha256": "1162c405a0075a9582fad9af179f8cc10143b13832e313d917b82e6dc41284b9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-SemiCondensedMedium.ttf", + "size": 196804, + "sha256": "5af87604ced7d8118aa7d685ebdf98805dcb70fbb3638521a0119ffa7d259cbf" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-SemiCondensedSemiBold.ttf", + "size": 198068, + "sha256": "53fff05ee2b810cc519db0ba8507d8fa6a1b3380ffd2b10a99468d359ac67b48" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-SemiCondensedThin.ttf", + "size": 195788, + "sha256": "510d26ec798eac7347db6ef73531b498713ed036d3dcc31bf9428f86dc8174da" + }, + { + "path": "/usr/share/fonts/noto/NotoSansMyanmar-Thin.ttf", + "size": 196844, + "sha256": "607e54a22387d94f12b7ee2a33470d003d808c7a5f2dba689552d3b67ce721e2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansNKo-Regular.ttf", + "size": 39592, + "sha256": "c756efb2c40f754107d76fa4e401fc3b8b7edec5cc65db549d3d0236ac6d08a1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansNKoUnjoined-Bold.ttf", + "size": 20324, + "sha256": "e37e90e51f8e9949925d85137dc7f4ea2a4ebe5857a2ba65223dbccaa8cf9140" + }, + { + "path": "/usr/share/fonts/noto/NotoSansNKoUnjoined-Regular.ttf", + "size": 20148, + "sha256": "17d320a99b955009573a56774cac14dd6d59bcbd5195b9b5c9bcab7d20cb7cde" + }, + { + "path": "/usr/share/fonts/noto/NotoSansNabataean-Regular.ttf", + "size": 8048, + "sha256": "90e722ed5cbcb21996c73a8156d04474a8a0d465f1b90e77a2152f28cf630fd3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansNagMundari-Bold.ttf", + "size": 17960, + "sha256": "e9d8b1accb5987350d40c45116dafc9f5a89e14734b28f532a78bd110847f767" + }, + { + "path": "/usr/share/fonts/noto/NotoSansNagMundari-Regular.ttf", + "size": 18160, + "sha256": "e822d0cc7998d8e94f73a734016b6882d9f8d570244cba9a29a1447bda708d26" + }, + { + "path": "/usr/share/fonts/noto/NotoSansNandinagari-Regular.ttf", + "size": 210804, + "sha256": "6a09072dfc8ec18e66827914768dc727d92a5d1f3873ba116c7ecc39715c0ad9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansNewTaiLue-Bold.ttf", + "size": 18584, + "sha256": "2a46f211bad3ba1b28eed5de0a3ed13906d511bc48a0aa62f75700070ceaa19a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansNewTaiLue-Medium.ttf", + "size": 20032, + "sha256": "db88b9b7d8e004be769e99b3a8532162a9e2f55b22fb53a1e21eb7cfdeac69c7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansNewTaiLue-Regular.ttf", + "size": 20288, + "sha256": "35499f3b79c2a4683ba2784b0d4c652d58e2c28c48775b33c9e8e94f2dee965a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansNewTaiLue-Semibold.ttf", + "size": 19872, + "sha256": "a390543d2972ec9afdae179e5d2e4dd0eb2d7f5fa0452a88d4cb26f4e3a364e1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansNewa-Regular.ttf", + "size": 152712, + "sha256": "3aede9a3ab5740babffe75f7c0d97e2329734b8cb470c999be1871362084a8bd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansNushu-Regular.ttf", + "size": 116276, + "sha256": "12f749af9fdd185ac807d325499f7c9e04896c4f8ee7ca3c96e96ed8c130b181" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOgham-Regular.ttf", + "size": 4396, + "sha256": "5b3705f2dbc34a493eaa968af282456f319dd74cd230a61614d5b7f6baa31121" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOlChiki-Bold.ttf", + "size": 14964, + "sha256": "646c28f9eb36ef2192f57231c9978d421b46ad2c1e954299ef7adab8cf0c071c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOlChiki-Medium.ttf", + "size": 15568, + "sha256": "a59fd8ea1490e579522a44182758751e0294675b622197267f814f3b59d1ca5b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOlChiki-Regular.ttf", + "size": 15684, + "sha256": "4b1ad6ec4a30277c75fd66465b0b9ae489e19f8fdf44ef3179b6fda7f5ceb32d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOlChiki-SemiBold.ttf", + "size": 15216, + "sha256": "e31ec252630f6bdce04f25d26171cb20bfe97517fc05adcb1259c84c77d3a0fe" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOldHungarian-Regular.ttf", + "size": 53616, + "sha256": "18d354e16832c85da1cfbcc2a13f95f375955fcab69428b03b566d8ab26959e9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOldItalic-Regular.ttf", + "size": 7220, + "sha256": "71ceb944bcbb377b2684110fb700cf8274e9064c7043354baca00c8e9bb939da" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOldNorthArabian-Regular.ttf", + "size": 6912, + "sha256": "533c06d36744a09dc1df0c7bc4f3022a839cedc354446dca203fe0895317ea3d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOldPermic-Regular.ttf", + "size": 9696, + "sha256": "ade73eb88c82cb9dbe7c86179c3f995f4fb9aa661d647c0c3677ae7b7c0d6bac" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOldPersian-Regular.ttf", + "size": 12436, + "sha256": "2c360d14a079590fd34d2989e26bbda7edd237701d0c22fed8350a2d8fffdb12" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOldSogdian-Regular.ttf", + "size": 17696, + "sha256": "a3b8c9799aab7b9b9170b6eb4955e8d8c58e52c37266faaca168d9fbaea65fec" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOldSouthArabian-Regular.ttf", + "size": 4960, + "sha256": "b8e196d9c4f8b634aa599fd8914179df7a00dce5da61a69762931b0c9cbef603" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOldTurkic-Regular.ttf", + "size": 15044, + "sha256": "2bdea6041ab232cb5d88e719dac536d387b0830b690c83d4a501d06265d89d84" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOriya-Black.ttf", + "size": 128752, + "sha256": "36dc60543c1a8401f34a773fe1080f3a5c4dbec648296613dbf30ae58cd84f48" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOriya-Bold.ttf", + "size": 130104, + "sha256": "1c575e8aba40d6baf200e8abac534e9517f431d38cc8131715643037eec149da" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOriya-Condensed.ttf", + "size": 130308, + "sha256": "a2b4d07d679a85827602c394b27414d66b260e5f15b763235e2270e57dbc9310" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOriya-CondensedBlack.ttf", + "size": 127636, + "sha256": "02d324513e727ba629fee094da129338afcb7288768a476b119ee8644e25d6cf" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOriya-CondensedBold.ttf", + "size": 128512, + "sha256": "91abfb913190664a0989a6cd7dce932edb99c18a107a912fc3a7b32d89a1c000" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOriya-CondensedThin.ttf", + "size": 131960, + "sha256": "576f50ff7ac51ec813e8580a39152d515d71824dc54b53b4964e894585f91b2c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOriya-ExtraCondensed.ttf", + "size": 129960, + "sha256": "373524cc07b3db024eccd0919c9af2921f31c092d9f3c8a26c7b569eef521d9b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOriya-ExtraCondensedBlack.ttf", + "size": 127956, + "sha256": "43bbe64f5f4551337a1f6059b6c2c141b1f3115bb1c3e7470121349c17a9369e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOriya-ExtraCondensedBold.ttf", + "size": 127740, + "sha256": "ed6a6d62b11b5de9c7e9190085eb67ce7875178bdb7d0329c06b7851a6743e9c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOriya-ExtraCondensedThin.ttf", + "size": 131200, + "sha256": "51b4ed8b5ca9da0740201a36ae6363a6a44578a3682238d803b5c7adb4870478" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOriya-Regular.ttf", + "size": 131216, + "sha256": "a16645d056017927406546aa78e4ce15e782fd8783467267b75450453d007415" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOriya-Thin.ttf", + "size": 134440, + "sha256": "95b6a9f97afa876d336b82ed948cf58fc9d3bd83d23bbe072b3d453fa1963aa9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOsage-Regular.ttf", + "size": 20596, + "sha256": "7801aa3d99d9da1a8fac388104e9fff4d94ddd28a07579b13a75224660b22fbf" + }, + { + "path": "/usr/share/fonts/noto/NotoSansOsmanya-Regular.ttf", + "size": 16584, + "sha256": "85b6f588013bdd027a683a454b5ecf4b1b859b7175ea98219f645be53dfc4753" + }, + { + "path": "/usr/share/fonts/noto/NotoSansPahawhHmong-Regular.ttf", + "size": 18168, + "sha256": "93c0c23cc50e5fb1e82a0eeaa8cd2e73d80e5af07be4419b882a2b54898def41" + }, + { + "path": "/usr/share/fonts/noto/NotoSansPalmyrene-Regular.ttf", + "size": 15380, + "sha256": "8085c705fedb4a01ec4b9044fcf55fa2b389aaf89ac48894f14b538381cfd7b6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansPauCinHau-Regular.ttf", + "size": 9048, + "sha256": "921cdd68b3db2ac8ce8c48aafb09027845db8d8360652e4446ee701f066a98ec" + }, + { + "path": "/usr/share/fonts/noto/NotoSansPhagsPa-Regular.ttf", + "size": 39668, + "sha256": "ad1d57202a3d71241822d984d53b4f458a89fa403c73e56010ea65fb367a9299" + }, + { + "path": "/usr/share/fonts/noto/NotoSansPhoenician-Regular.ttf", + "size": 5912, + "sha256": "b878beb30d6cbd45eb3966e8409caa746ae1987c5fa9856d8e51a7ae36e82d80" + }, + { + "path": "/usr/share/fonts/noto/NotoSansPsalterPahlavi-Regular.ttf", + "size": 35436, + "sha256": "9760443febd572a8f98cafc0af252ff64a06793d9dc14a9fea225339aaea9e57" + }, + { + "path": "/usr/share/fonts/noto/NotoSansRejang-Regular.ttf", + "size": 7704, + "sha256": "ac84f549caba39e85d0d22a62f4fcf7dcacdf263d01527eb44388e96ce58d785" + }, + { + "path": "/usr/share/fonts/noto/NotoSansRunic-Regular.ttf", + "size": 9856, + "sha256": "c43f941ef6c8a4f8217e7ffb332fce74f760140a7fd5919f86347e5e2a10b5c9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSamaritan-Regular.ttf", + "size": 10788, + "sha256": "430aa9e70b6c500c4267e9ecc5165d15bfefc761d86650101deeb7e07260d5ed" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSaurashtra-Regular.ttf", + "size": 35252, + "sha256": "8ebdec75287c63310a1aff3660d0fa5b3dd027606c17f69217a5a5a5142f6e63" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSharada-Regular.ttf", + "size": 53372, + "sha256": "8350a4cf4b5bdc0707aa5077075b0885a4f3f0f08ed6b49b8e5d142aef4032da" + }, + { + "path": "/usr/share/fonts/noto/NotoSansShavian-Regular.ttf", + "size": 12996, + "sha256": "6e2f222f7a24578efe1fd112678e7fe3709e8157793456c75053617848619d1e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSiddham-Regular.ttf", + "size": 144848, + "sha256": "a384592b36df5a2f283610c93703bcfb72e6fd86bd2e0a699da1eff91b247e88" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSignWriting-Regular.ttf", + "size": 5214924, + "sha256": "3c18f0954681566082860d468c1232dacbdf59d22f061a019095772e3d7cfdd2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-Black.ttf", + "size": 200552, + "sha256": "9417630d7099d8225fe381cca04ebd127d6c08206afba21e1c154c565d522e04" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-BlackCondensed.ttf", + "size": 195608, + "sha256": "ba28b81f115193aabf5bae01d13af526a06467e6edc7fc6fa847670564bafdbb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-Bold.ttf", + "size": 192976, + "sha256": "e1f8867effb18940b02173a120641e526e5aaa8efc176870e62b2d49b1e6fd3f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-Condensed.ttf", + "size": 166780, + "sha256": "210bc0a959bf7d6fae7de7a306431d43aa04dbfff4e2151411765293ce3fe0e3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-CondensedBlack.ttf", + "size": 349220, + "sha256": "82972ebb981addce43b54a116c3745fce55e669692de8d63859f6d41d6a2dce2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-CondensedBold.ttf", + "size": 351956, + "sha256": "73ae9cfa4b5162687eb58a257b1f1fae0d8cc0fc5217329a5fe0c689005b322a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-CondensedExtraBold.ttf", + "size": 350960, + "sha256": "0f386a87791ae193400a5d8f87cb6abd6e54cf936d5e2354589a4e22fc9c6d70" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-CondensedExtraLight.ttf", + "size": 279608, + "sha256": "298d0ae36972c925222a1fbe5de3e05423021eb6462d1722f3a302e66005e813" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-CondensedLight.ttf", + "size": 323288, + "sha256": "f86ec9b65b50a391a0ba3d572d8b6a1de2f3bb45e8c54a915f78cf250cb99d81" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-CondensedMedium.ttf", + "size": 309320, + "sha256": "8a53997f6c0ab517f4dfa853ae535d5fa4499627f6df5e6437c3f99915e59efe" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-CondensedSemiBold.ttf", + "size": 346652, + "sha256": "825e87948d8482be00cb1db2f14f36a5ec1b75d8d2e4b30bf47e9bae7e59df0a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-CondensedThin.ttf", + "size": 283384, + "sha256": "8f529e96ea5d68d788dd4a5f907609c6c02d068b0a91353cba76ce39a6f3d051" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-ExtraBold.ttf", + "size": 198972, + "sha256": "fdcda9771808ab377c70611ca6eb288ea2d2e4463a65e4a6c81233dd0d3e8094" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-ExtraCondensed.ttf", + "size": 320364, + "sha256": "9e4c5ce95ef5fc65ee3cbb36413ad96faea09b8a263dbe083cb23d7989e2e100" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-ExtraCondensedBlack.ttf", + "size": 337040, + "sha256": "e81ac0f70c718d0f77d56fbe5b54ce0dd7eabb4f820016aea7f1c10fc9077306" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-ExtraCondensedBold.ttf", + "size": 360588, + "sha256": "17ad1c90dbe26a20dca823b6e086be1cf3d3dff84e0eb1a012780b3527f13551" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-ExtraCondensedExtraBold.ttf", + "size": 340600, + "sha256": "fdd8a4edaa4b33be386a13a870690627a9e8ef7cb22d58b07d0df48cc75f39a3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-ExtraCondensedExtraLight.ttf", + "size": 278840, + "sha256": "1cda319cacdeac41aa399daabe162b3e05ef2d128d27213cf1f73b92175bc912" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-ExtraCondensedLight.ttf", + "size": 316840, + "sha256": "bdad60ee91b8f2e4456f9671d9683801efbeb4ff00cb095272e91b3f651a7122" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-ExtraCondensedMedium.ttf", + "size": 316608, + "sha256": "4836366e0d8a69eec2492d0402dd9801ca6ef74971e262730f937ad8f07145eb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-ExtraCondensedSemiBold.ttf", + "size": 346376, + "sha256": "21d41f0f2c1c41d91558205bca6698eaf1a073a867fce2f5ab4189f282e15aca" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-ExtraCondensedThin.ttf", + "size": 280852, + "sha256": "27a09fce50a09870ef9fad4e0c4c69088bf8bf8617a6ea50102d937274e8f827" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-ExtraLight.ttf", + "size": 148196, + "sha256": "b94a9401ed96a8b403bf72a8dda808b3fec9225242e326faca6bdfb8a0873092" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-Light.ttf", + "size": 152464, + "sha256": "e8772c76951069c453f8c58d3728c637ccd07f6bd4c291c1a5e826a2c68cca94" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-Medium.ttf", + "size": 162428, + "sha256": "b17502c04bdd7c530672ce084d68e4b5eebdde60e52b239806c4cd62667a48e5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-Regular.ttf", + "size": 154912, + "sha256": "9e32612d47004552f3125e78648a9e2e7899a216ccd3cefbb93a9b5f4c809feb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-SemiBold.ttf", + "size": 188052, + "sha256": "2d4462d772672b247740ec46c69f8e9208a87156ff23e390923db202351068f2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-SemiCondensed.ttf", + "size": 309532, + "sha256": "3e5345d2257310478ee5ca84301f418961b12855f7b549b1dc2c7c7397e221c4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-SemiCondensedBlack.ttf", + "size": 353744, + "sha256": "06ac2c8bd350ff289a8911a90700422afd925f257c935eb40cc23b2c7cd73c63" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-SemiCondensedBold.ttf", + "size": 347008, + "sha256": "bedd5e2899fc576b0a1f5d361db2489230925260246a447e36dbb8a1aa86c6cf" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-SemiCondensedExtraBold.ttf", + "size": 340704, + "sha256": "198c220016bdf6f22b8cbf9012d76959857bbbe3846b2fc03da233422df7c59e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-SemiCondensedExtraLight.ttf", + "size": 293496, + "sha256": "5e3dbab7860d8ab32ef7ecc6bc1103b9f44a9c324df98e6a974ada95c95c734c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-SemiCondensedLight.ttf", + "size": 324528, + "sha256": "7704841b855d0991a3bbf447322127601f6223199b60001bd7f303aa7deeb953" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-SemiCondensedMedium.ttf", + "size": 316044, + "sha256": "5b49f4bac87a79e280620c4224d44100fd22667568424c4ffdaba93eaae23cd6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-SemiCondensedSemiBold.ttf", + "size": 343376, + "sha256": "a0f27cb427b2be14c8182697387e73d23e4fa42619d3719b2027df25b83ba2d7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-SemiCondensedThin.ttf", + "size": 284204, + "sha256": "b332cbedecd2b4b6ae74d2e294739f88460d9d102ad7981726260e0636d67e2c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-Thin.ttf", + "size": 140448, + "sha256": "714bae6814d60239a486764f4a45a012880efe868bdaaa93cf822f0ff9217be9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhala-ThinCondensed.ttf", + "size": 141052, + "sha256": "a5e2fef3618a08f9a20af12e1ffe8123cdd892e4990c1880adcd772207937ab4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-Black.ttf", + "size": 358356, + "sha256": "c04c49c75083474549538ef45624cdb1d06072d43e3d9ed5d7cf308acbcb0f85" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-Bold.ttf", + "size": 341236, + "sha256": "733398c404b111232d1e36b5f83c1887ba999461dbae6d1eb86b9050bc3955cf" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-Condensed.ttf", + "size": 313768, + "sha256": "bc1d82b6b3da8b140b6323ae4f16e84d4e211e85b7f8742785af8c9c232c70d3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-CondensedBlack.ttf", + "size": 349240, + "sha256": "796bd4f3b94cd612a88490d61d1e4e1b6e49a95ef678b8f8012d56f0cfe02330" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-CondensedBold.ttf", + "size": 351976, + "sha256": "0c46412c9135c8beceafd5ca93eac10d6417d2f3771e7d55e05b0fd0c171ba82" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-CondensedExtraBold.ttf", + "size": 350980, + "sha256": "8b143aae50524d75e397116567e57bf493d74ac39ad3295bb51d0f96022dcc19" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-CondensedExtraLight.ttf", + "size": 279628, + "sha256": "cf6b67ef64d065107f8d1e53886e002b40cf8717d6e1d6d71947de5788c6c5e0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-CondensedLight.ttf", + "size": 323308, + "sha256": "d6db77c88862456b43c53158c2e0de46f003834ba6124327cc8024d8d80010ff" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-CondensedMedium.ttf", + "size": 309340, + "sha256": "f8306110974c22e263e3a19075b4b71b3f13a2d70851b23d5f0e479861b90d78" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-CondensedSemiBold.ttf", + "size": 346672, + "sha256": "a7a4167fc94f1c51c9a3ffa4de410ec726fe5983f26f5041d7f0f9b1fb3b861d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-CondensedThin.ttf", + "size": 283404, + "sha256": "df6aa71c87d747cdfec27b175efa94f3d9d03fbc0bce7cb4ebc9b5b90f9e51c6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-ExtraBold.ttf", + "size": 346028, + "sha256": "b06a0bd2a242c6f40434c171f79fcc909f4018586e3fb82d02625032d011cc53" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-ExtraCondensed.ttf", + "size": 320384, + "sha256": "906f907702e542af8d6f7589cad0e4ba52bd56cbaafbceb30ccf9c6ace25e7b2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-ExtraCondensedBlack.ttf", + "size": 337060, + "sha256": "2f0c8a18af917a4cd8fd94a0a20b7b9ff1e48a3ddd0d8dfe832670d40b195614" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-ExtraCondensedBold.ttf", + "size": 360608, + "sha256": "83eb96fa74863e54bc4187978b3983215dc5b84bbccde607cb3bd924c3cf25e1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-ExtraCondensedExtraBold.ttf", + "size": 340620, + "sha256": "027db92158e2e8cf47c5dc806896433181d254710ecf6f10ad707cec6fb609f6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-ExtraCondensedExtraLight.ttf", + "size": 278860, + "sha256": "26c80089ed8cf8c458b29efc8c1a4e41680941f00fc822fa70fb8bc1ca93832d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-ExtraCondensedLight.ttf", + "size": 316860, + "sha256": "1c2415edff67e799b363056b2e06e0691b7e8e1db6e1a1026c211562cbf98b49" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-ExtraCondensedMedium.ttf", + "size": 316628, + "sha256": "4f945ab766a43821d34f1fb8a41b7fc7ad4fe3df70b74ac52664f1856d7b064c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-ExtraCondensedSemiBold.ttf", + "size": 346396, + "sha256": "e8c2f5a6565baa3a6bd784198bb7485322363c4dc008aa6198800abec19c234e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-ExtraCondensedThin.ttf", + "size": 280872, + "sha256": "bb35b73a17c2ddd0639ae5a056335f2d8c8462e0c3e373a33a543afa0ddeb7d4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-ExtraLight.ttf", + "size": 293228, + "sha256": "524a10373729d462ef258f26570fae5528d0acc2bfdcc5107d764c03ed1940cc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-Light.ttf", + "size": 324800, + "sha256": "08426b28d16ef0f7884bd2c6d3df9473a0f4fec9af9306e3daea84b5f86caf56" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-Medium.ttf", + "size": 314260, + "sha256": "0d793f61deb4d484f37e6e3191ec6f8154c25225fd6fd66fc0668159926edb64" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-Regular.ttf", + "size": 308984, + "sha256": "2ac18d3ccaf1971f8c7c3e57397ac2996fb5ccc0b3bb64e3a73bb794d0d931f4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-SemiBold.ttf", + "size": 337008, + "sha256": "5965d4758b7b8db0554fa284321317c1e3c27e9a28d51e3e6334c53680c04a4b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-SemiCondensed.ttf", + "size": 309552, + "sha256": "f1126912f0c38a23718d298cf95a9f227b7880432e3061b9333381ccfb63989d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-SemiCondensedBlack.ttf", + "size": 353764, + "sha256": "87c7eb22e3c202abfaf2f3fcf7536c72834266dc189cc889563573249c7f1e52" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-SemiCondensedBold.ttf", + "size": 347028, + "sha256": "efd7f7e3af1de25eaaa1d2dc9f5222e58b983c7aae285538b970f369ef6b6874" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-SemiCondensedExtraBold.ttf", + "size": 340724, + "sha256": "5638303c7fad8d7b57ae0c220121db1e367e54ad17e3f4e2fa03a260cca4d6b7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-SemiCondensedExtraLight.ttf", + "size": 293516, + "sha256": "913183ad18ed20ac7a8b954c4f12ae92c71801d483cd201478f966dfa2fe3464" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-SemiCondensedLight.ttf", + "size": 324548, + "sha256": "c94caa340f483c09eda2f3e4aff74c7a75be357c193a7485b1b3623a87da23c0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-SemiCondensedMedium.ttf", + "size": 316064, + "sha256": "6ddaca106c729b558f4b7f95f1f94a710f7f7bd403aea9ade93b9e01ebd496aa" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-SemiCondensedSemiBold.ttf", + "size": 343396, + "sha256": "a0d03db38e2c00fa1f12fc6fe5b74f417687591b72641469811360913a1182e1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-SemiCondensedThin.ttf", + "size": 284224, + "sha256": "262672d831239274d507f6950048ba40dc19f22690956a3ebafca6e2e2d16f4e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSinhalaUI-Thin.ttf", + "size": 287404, + "sha256": "a557221446b77843f6864632b1f3833d90f1c0f1fe937378a8c74e4f13ff0271" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSogdian-Regular.ttf", + "size": 96428, + "sha256": "5941add28bf1ecd63ad663cd2116948ee11d48687cd196c20eeeb0913267a026" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSoraSompeng-Bold.ttf", + "size": 8208, + "sha256": "5e4de174f5e11ae5a270c864097374dcf1a46e0ded4717f8616b6a88bcfa1193" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSoraSompeng-Medium.ttf", + "size": 8064, + "sha256": "10d71323fba9242650db19230e7fc83e7083713c8fafd7490d7e5f439b518d2c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSoraSompeng-Regular.ttf", + "size": 8048, + "sha256": "3499f8e787031f75771e481a53ca41f6c0b65dbdf149dac3cb319c29c53c28c2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSoraSompeng-SemiBold.ttf", + "size": 8104, + "sha256": "3ea217ca2ff8067293325b35f3398bce8ac0cbed0e5f8a45982f6e0fe504849f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSoyombo-Regular.ttf", + "size": 66356, + "sha256": "31a5e5997360e6d1d574af04da3eefaeb3db18831df795c145634e517e554e1b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSundanese-Bold.ttf", + "size": 22712, + "sha256": "76d6cea022013876651197f42990970ea197a28282c6da57f3f345db76453272" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSundanese-Medium.ttf", + "size": 22452, + "sha256": "1c1a952bd6740703cf9238e61b4196b89cef98e1b144320475b8ed29b127cdfd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSundanese-Regular.ttf", + "size": 22640, + "sha256": "6cf8ae0d063b8af8ab1bc1b8c7041ac92104e74989dcc1217763723ca6a50c9e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSundanese-SemiBold.ttf", + "size": 24092, + "sha256": "7806045b6e8e2d4c7e7dcbcd25b1d86c1aad3208dd80b3ab40a4e1557f3574c0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSunuwar-Regular.ttf", + "size": 14596, + "sha256": "0d66add3f652f618c9ed996efb1b425a515231b313881b4c06b2e1c6d0700525" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSylotiNagri-Regular.ttf", + "size": 16740, + "sha256": "b3bb806a5fe533e550a27b51b28961cb09522f2a700c2c858169d9c8ed6573c4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSymbols-Black.ttf", + "size": 235968, + "sha256": "637e3857aa729ec67aae4bfc6838cd298cad2b4a54de953916f0b1c5e10b2121" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSymbols-Bold.ttf", + "size": 228028, + "sha256": "af83250e7c5bdd37d542187a724ac6b33453a6550e741cebcacf7a771891f75e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSymbols-ExtraBold.ttf", + "size": 229908, + "sha256": "3b603fecbbd9ad1b45223c6b7871cf9d54b653653fade3c1da0bdec98cef8740" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSymbols-ExtraLight.ttf", + "size": 232496, + "sha256": "a14c578c4b8ad2142a7830225a5d215ed15c7e2295cebd7e211614336e2288fc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSymbols-Light.ttf", + "size": 228800, + "sha256": "659843a49ef43ff42f9c315285a9e8b828853c46496f8bed426800b68b10b697" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSymbols-Medium.ttf", + "size": 227616, + "sha256": "f2422696752b46d12ff32c4abfe5201b34c9b83404fc5da6ccab05fceaff6bd3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSymbols-Regular.ttf", + "size": 226980, + "sha256": "d0e98e9a2c046594c5021437273943be7e79e0fd980fde125279e22302212595" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSymbols-SemiBold.ttf", + "size": 228356, + "sha256": "9ab8ee074de01e611ee9085c4721eeaa02efe815dea7b32f682bba7dd6fb6afe" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSymbols-Thin.ttf", + "size": 231428, + "sha256": "b3f87bf5316769f72382ac64cbefc08030350028d4dacb5622a1c5cefc6ec6e9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSymbols2-Regular.ttf", + "size": 671568, + "sha256": "c4a0a80f0041ce4be81e2478faad22776d23edb98ae3f0d19bd37044820ecf9d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSyriac-Black.ttf", + "size": 75804, + "sha256": "1213af52c501a1430a15bb15fbd5509ddebf66aabfde7ffb1319349d99986287" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSyriac-Regular.ttf", + "size": 73796, + "sha256": "4440929bf1a47bb50179e8d8495641d208c63d607355e451f29ea2d3e5343290" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSyriac-Thin.ttf", + "size": 73192, + "sha256": "21ad9d3470577b133ce898ed20e7f5663dec76661f8f8c4cb831f50bc136cb93" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSyriacEastern-Black.ttf", + "size": 62536, + "sha256": "9b233ea6632d6bf539caffb3c94dbe6cedf1a8c5380e1759958a4ed20ca67b59" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSyriacEastern-Regular.ttf", + "size": 61312, + "sha256": "5be6ac640695988bf8f334a1300853a2415fc51633b321f7dbcc05c30b2a01ae" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSyriacEastern-Thin.ttf", + "size": 62024, + "sha256": "04427af49a5d7f88addb02753f0d37e93cb92330118e7d5a531406403c33da10" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSyriacWestern-Black.ttf", + "size": 78952, + "sha256": "7b3ac5dc7a67e11d3e41ba57b40d69c648f14795911ffd600057a1ab47aa24b0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSyriacWestern-Regular.ttf", + "size": 79396, + "sha256": "fecd6eecb3d0bcd959d1fa5eec5eb696f60cb907fb15f292a53ee4bf04e3b3f5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansSyriacWestern-Thin.ttf", + "size": 78708, + "sha256": "7216ed50c882cdea16ac1bd6eb3d5c335433d919eb9264ec44c0778e9e7c17d4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTagalog-Regular.ttf", + "size": 7060, + "sha256": "8fdf54f14d3869e5bfabb7c552e32ac39a2f7609cc6e857a2e8480da2ad9031f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTagbanwa-Regular.ttf", + "size": 5940, + "sha256": "5ed080972eead72d6f7f074ac2020dd6f82d8158161040f702b539e43a776a28" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTaiLe-Regular.ttf", + "size": 19476, + "sha256": "25d2ef9aa4438c7b651ada7f32801e196b5a4a3ca8d6e41cf36e37ecb99aff22" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTaiTham-Bold.ttf", + "size": 116848, + "sha256": "57db6c21376590ce0bbe1ca8f4437414f80dd703b320a94506a46ab6e3defdff" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTaiTham-Medium.ttf", + "size": 118420, + "sha256": "a387dff2406a337f3fdbf09ee49734003d3e40d16bb14e01d2675fa7c7b9b56e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTaiTham-Regular.ttf", + "size": 116880, + "sha256": "b94134811a2f8c26631a728837bb72f74dad87402810fc46b34c310312a6b369" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTaiTham-SemiBold.ttf", + "size": 118420, + "sha256": "f6ebcf2e2295e871af5a87c0468244477a972249650e3bbca921df02da74d880" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTaiViet-Regular.ttf", + "size": 30580, + "sha256": "5f7e2a8d5cca567620d101534fc34b432e1577ff01f3f1752750050c90b1ca7f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTakri-Regular.ttf", + "size": 18456, + "sha256": "c14fb3d442ee9330d54d22aed37a9ee78ec4038055faa195d62661e4bb9988f9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-Black.ttf", + "size": 76300, + "sha256": "61698d2be66329c593ffec84905428e6292236ed04a34067ef59ec5cce367411" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-Bold.ttf", + "size": 77176, + "sha256": "683682d585698b8b44da066d4903762d8aaa471bdffb919ac869753689ba3950" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-Condensed.ttf", + "size": 88016, + "sha256": "b8f1c8ff8b783d97655b6494bd5fe0257e9925ae06e78123ca414ed0aa9b42a4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-CondensedBlack.ttf", + "size": 77868, + "sha256": "90f53dd38a578e43782509760424a291923cd5339c6cdb222ad9c6ba43c143e3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-CondensedBold.ttf", + "size": 77216, + "sha256": "37f933549397e2baf4a709ab6deb88938f28d2086a509ef8226879d0fffb1ec8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-CondensedExtraBold.ttf", + "size": 78532, + "sha256": "94cbcfe410d425d9128c7dba81fa2c1d47e7832b0d30d07b92dbead974f28d77" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-CondensedExtraLight.ttf", + "size": 65644, + "sha256": "a3b4138eac4a35932959efa6cd8f09b443e1e3daab223f5df60ed929ab3b822d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-CondensedLight.ttf", + "size": 65832, + "sha256": "be0c824213fef834c8665bfa0b6051910863a7ce21dde922c9a97d4898a3dc6d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-CondensedMedium.ttf", + "size": 74284, + "sha256": "bd6eadf1b4c04eb016416d5fd0c659387e881020e65200d893ba9a3aaf6450e4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-CondensedSemiBold.ttf", + "size": 77488, + "sha256": "777bed694c04b21db3858986c80b0915b9d717bafd6e80ce4b246c1449143672" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-CondensedThin.ttf", + "size": 65704, + "sha256": "98493d2b5eb7d10706e3b1a51ce016405c20605774cf11e5fa89964f318bcceb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-ExtraBold.ttf", + "size": 77672, + "sha256": "9ea8e951eca00fc80c9fa5a57c3d232a7be55335d4e66d2dfb390c747fedf177" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-ExtraCondensed.ttf", + "size": 87892, + "sha256": "fbb1566862ac9e3d60cb72ae35d576adca0101f7b6342ede1137aff365e940f2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-ExtraCondensedBlack.ttf", + "size": 78548, + "sha256": "75d9a10e0a3bf10b92c6e253305f1820f6ca00e62e7408f4a98ead428011d0ac" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-ExtraCondensedBold.ttf", + "size": 76284, + "sha256": "b6855e94a28cc95a254e78d9de62ef05068dd7669a713a7d62000c381dfe7d68" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-ExtraCondensedExtraBold.ttf", + "size": 77712, + "sha256": "920525da606f2c4d156202ff30a549444ecc1e231a39734dc2eabe7a3aacb0f5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-ExtraCondensedExtraLight.ttf", + "size": 65592, + "sha256": "db218b9b92ca8c61b08955e7d76e0ec58026df33891958de226a85701530b22a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-ExtraCondensedLight.ttf", + "size": 66400, + "sha256": "d65def4812e96f4cde37cfd48d7f4f07c12b9f37167dd007d93b3735776ad604" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-ExtraCondensedMedium.ttf", + "size": 74752, + "sha256": "d758b1ca06c80d4478844e309dcd394bd952bb12b5ea19bb9068cdfa1b5a4e68" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-ExtraCondensedSemiBold.ttf", + "size": 76120, + "sha256": "7cb8da04b98c2e34490b6804e58a91378996337496d4ffaba3bb7c759ce6bbb0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-ExtraCondensedThin.ttf", + "size": 65344, + "sha256": "b2687eb2745e7fbed3aec2ab5c9813b603d3b8e2617dd3050854a70fbbf306e5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-ExtraLight.ttf", + "size": 65924, + "sha256": "23630e9c867f572bc416fffb7794094b540f2f3396e09617d21e3f92e7420278" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-Light.ttf", + "size": 66344, + "sha256": "a1b2b70cd21f7ce05d2ed3ad116f6c6789cc5b8bfaaf2cb2997b11f6cc97cbb6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-Medium.ttf", + "size": 74124, + "sha256": "41dfbdebbefd8c9e02331d2bdf1e2869b07858a2306825f713dcb4cf7f8b57a3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-Regular.ttf", + "size": 73992, + "sha256": "3c0a186feb3c63c7f6d63e1511dcdc144e745ae09b98e217c83f3e317974f6f9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-SemiBold.ttf", + "size": 78788, + "sha256": "1a56ae4581b0d98c553f8fdf84e94c5a93ca51fb20fde31967cd91e62057ff5f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-SemiCondensed.ttf", + "size": 73532, + "sha256": "ef99ccac7b2a4b1085ededee0cd4ee7427a2017b07a7e085d0e6e5f74f03b9ac" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-SemiCondensedBlack.ttf", + "size": 77500, + "sha256": "23b9d1a948cfcf0b8f8016ce91ff41a67e214ce45e74aee932e0065b5cc414eb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-SemiCondensedBold.ttf", + "size": 77268, + "sha256": "99d1fe3a15c6390a3c1884d81ae42bfead2b8e80493ff0ebce56818bd40b14b0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-SemiCondensedExtraBold.ttf", + "size": 77284, + "sha256": "37c0522fb80eb927905632b69f01d0b08b0f694e4c93dac62be9ef10c914ecee" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-SemiCondensedExtraLight.ttf", + "size": 65800, + "sha256": "333145ac9b8d8a1e2d49e6ad358eea365d898ebd1f1bc1cb200a480c08af3ea8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-SemiCondensedLight.ttf", + "size": 66036, + "sha256": "10839d2d0065a8a451bb52d2a34ce3106a0823e169102701329c056b257fe2c5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-SemiCondensedMedium.ttf", + "size": 74384, + "sha256": "8019b10d5c4c4204e699efe9efa8445b05cd09ba2b9d8b5607b95d478b55fe8a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-SemiCondensedSemiBold.ttf", + "size": 77004, + "sha256": "d5edcc8d034c1f0ad2732adbeed68754ff9552eb4229eb63a17fce57ebd8dea5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-SemiCondensedThin.ttf", + "size": 65668, + "sha256": "5ef17b9bbafd231eb308102052f1511f407fdbcfa3e3f14a0db71137c830941c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamil-Thin.ttf", + "size": 66248, + "sha256": "6204a34bf2b37c216b5e030ce2cf4f2e86469d7a1d70a03d3e09e4d4b2b01ebb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilSupplement-Regular.ttf", + "size": 15516, + "sha256": "bf332a302459232a2106fbdb72bab4fb096d164dd124c22bb990c67b90efc348" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-Black.ttf", + "size": 76368, + "sha256": "96553a0db2df8b91727c14f568ff9028056f3e0d68cd10bf8fe9b829f5e50208" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-Bold.ttf", + "size": 77232, + "sha256": "f59082007d60cdcf44b6c019cc878e452a30dd90593be5bbaab53ca5b0bd2a8f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-Condensed.ttf", + "size": 88120, + "sha256": "c2f62c33739b130850e1cf29bac185243189a138a974354cb05a78ed649b8ad7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-CondensedBlack.ttf", + "size": 78004, + "sha256": "f1e05688554cf1d3f7e45178559cd4e2a20dd834ab8ff06e2243573635eecce2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-CondensedBold.ttf", + "size": 77260, + "sha256": "742bc3d564b905002c229338bfbb032384b2b80ae064222b41707170b0134fef" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-CondensedExtraBold.ttf", + "size": 78596, + "sha256": "c699368dba24f88f4666cf12f3eee5911e09b04d70bb5a66ab74b161be2f0284" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-CondensedExtraLight.ttf", + "size": 65688, + "sha256": "0b5ca5290aa492e32832420dfee4f0fe34fcdbbf3a85845751352cfb2fc17ffb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-CondensedLight.ttf", + "size": 65888, + "sha256": "118b53ecf57eca0fdacd15a58673e4e485fb7e381cb4d22a30b18978794ed88a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-CondensedMedium.ttf", + "size": 74332, + "sha256": "e05041843add21bd80b06eda5819f77f462e7a514f7ceb9111f529ebea8cdfbf" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-CondensedSemiBold.ttf", + "size": 77544, + "sha256": "99867f9416d192bd215c267dcf26095c1d2d54f69f37cdaceaa329babf47d9b5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-CondensedThin.ttf", + "size": 65760, + "sha256": "32d20073aee6974bbd5c63c7eabf0ac58198d5299f528dd0c52c838d0c360307" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-ExtraBold.ttf", + "size": 77732, + "sha256": "92bef0b2bcb9fa83dd3bc848332110387803c9f9e4e51e1794cd329278119150" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-ExtraCondensed.ttf", + "size": 87940, + "sha256": "5d2e91589972452c54011195027cac11a5fd4833074a1016b434f7092b3f4af6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-ExtraCondensedBlack.ttf", + "size": 78600, + "sha256": "ced1ca657793e9ca83537b583b57aa8dfbe8b9cd02ca6cfeacc9358e1ef4dbcc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-ExtraCondensedBold.ttf", + "size": 76356, + "sha256": "5d804a44060b8df8d5dde89b84fbb92a07babad4c5f57acf69a27e31bcf73123" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-ExtraCondensedExtraBold.ttf", + "size": 77776, + "sha256": "7858f7eb0c679027cd4db6820afaed05423a424ab5e3245b8ce527ca04a1c6cc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-ExtraCondensedExtraLight.ttf", + "size": 65664, + "sha256": "4d1914da776d33130c1b77fa08fd25e88901779110782b8bbfa54d07ca504589" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-ExtraCondensedLight.ttf", + "size": 66452, + "sha256": "db7ebc67883fcd13ae6cc0f6e2f5052170d554686710e95bd7b1f64183de33f0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-ExtraCondensedMedium.ttf", + "size": 74800, + "sha256": "9e9b0cd095a2365ddcc1a763420b773567b5ece42319ad9af0657205594fe6cb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-ExtraCondensedSemiBold.ttf", + "size": 76180, + "sha256": "a0b5cba0b30aebbbcabeda9ffc423e51cdec8e27ab2e02d633cdd4e4d4466f87" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-ExtraCondensedThin.ttf", + "size": 65396, + "sha256": "eaddaa9b9805b02cd0c046f0ada75fb03947a5bc63d9033bc6d92600fa56da68" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-ExtraLight.ttf", + "size": 65976, + "sha256": "eef348f26e3a2a2dd3d6d69b5eef4ff49168268049b9e7dfbb78992eb027ed21" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-Light.ttf", + "size": 66400, + "sha256": "b1b01c4758e0f29534c1a5e8fa450e0968e5f1a0dbc69f3382a2bbd699c97ba8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-Medium.ttf", + "size": 74172, + "sha256": "524449ab65e2d2cea89d2b1b92a646f8c267ffc23fffcea96a5bb7fbcf7c4b7c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-Regular.ttf", + "size": 74060, + "sha256": "0ab5f6790da8b3ee5001934bf941ee0ff0f861b8c8816987d253e7e969431393" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-SemiBold.ttf", + "size": 78840, + "sha256": "a28832dab01288100c661b1b98a4c6a58d82c8cc8d83ea598c6f115aad9a09bd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-SemiCondensed.ttf", + "size": 73580, + "sha256": "82715c52d8b26e025e93e7f8eefa5ae0a865141d07effdf34fe86943b21f48af" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-SemiCondensedBlack.ttf", + "size": 77564, + "sha256": "5718d77921756191228569219932953bfde696f57167ab727604eadb6e9d57e4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-SemiCondensedBold.ttf", + "size": 77336, + "sha256": "afebe2904da5d161ef03435542d9511cb37d4a15d8b3333719856b05408420a0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-SemiCondensedExtraBold.ttf", + "size": 77348, + "sha256": "e0f71d3fa60332ba78be16a2614719ef55efbfe493345acd3651e58eed2b5402" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-SemiCondensedExtraLight.ttf", + "size": 65852, + "sha256": "76d2c12c3228028f7eee207018d023340186cf3e46cf7fb1ae8e3ec0a52a3ada" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-SemiCondensedLight.ttf", + "size": 66080, + "sha256": "172ed65939192fdf4b92174d810c728b207957606921af02ab92c26583b97ec4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-SemiCondensedMedium.ttf", + "size": 74456, + "sha256": "1e0444bc259e4f92bec041e646ae98577c15f32e914475f3df7058c3aa5e27c3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-SemiCondensedSemiBold.ttf", + "size": 77064, + "sha256": "e72cc68f1b22e4aca21b6d3ac38d2013d016feee2e07db3cf8e7a35fe33d24b0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-SemiCondensedThin.ttf", + "size": 65720, + "sha256": "2b01330be638b6788d2ad3598c9093ff118da0dfd29730fd0defbd625ba8d48e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTamilUI-Thin.ttf", + "size": 66300, + "sha256": "db5d7e08b1c3ab679b4021f4473563cc44f5fda27ce3cd7f78839189e49423e2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTangsa-Bold.ttf", + "size": 21572, + "sha256": "28c8b1a32563cc01a7cbf691bda714b1ff9ce9f75ac4e0341276a091f6bc097c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTangsa-Medium.ttf", + "size": 24000, + "sha256": "c4ea239bf55601f083b65966e479ebd1f0eb9a3571aa564322c96bf534616c37" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTangsa-Regular.ttf", + "size": 23056, + "sha256": "38d92ec4dbba7a23966c2de72736224f94d02e5fb2b4d839344e732cdf3a4cad" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTangsa-SemiBold.ttf", + "size": 23976, + "sha256": "5780303097159b7a2018233ffd8fb91ad0ff44dcd21a692e415e93094c5be91e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-Black.ttf", + "size": 247048, + "sha256": "42a3167fc2d8ca089b03dcbfcdfe673ecaa176545944ea9c50377144947ae383" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-Bold.ttf", + "size": 243412, + "sha256": "ec98c4f82abfe52ebe00be298d39c469deb86cf027929987759ba1eab361fbb4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-Condensed.ttf", + "size": 229516, + "sha256": "4a9ebfb9abfa069c11d7e65e19b38808c4e3dfe854e3316e71a96f0be9a11302" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-CondensedBlack.ttf", + "size": 245348, + "sha256": "ff7a6fbc4d2bc590299472c5f48267c63044c6beba44f5aa478d54b493b6decc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-CondensedBold.ttf", + "size": 243712, + "sha256": "a350c9660ff9c28b1687b43c781e882f188bf2b817330e091bc828d7e3359405" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-CondensedExtraBold.ttf", + "size": 243592, + "sha256": "43e04233ee0ba780af7cf0428bdaea400f14926653ed2274f53658e34bcd869a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-CondensedExtraLight.ttf", + "size": 221716, + "sha256": "019a5d50c10bea5ec6579e2d9fdd6cf995d113e23c583d9a9c6f93f835fb824f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-CondensedLight.ttf", + "size": 227932, + "sha256": "8f4a73b47ffe868b51f7d33d9143a7db0432237ba7a1d897ce11d1ffd65531b5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-CondensedMedium.ttf", + "size": 239020, + "sha256": "2dbf144b72d8aebe8d713bfa53947da3d247bf59deb033129378136e8c48d66e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-CondensedSemiBold.ttf", + "size": 242164, + "sha256": "ebdbf5d1aeab6a3b04c6bc8c7f639ba548d8a7888e72dce2421f11e83ac0a197" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-CondensedThin.ttf", + "size": 229916, + "sha256": "2c06b1d5d54bfd0558dabcac69736147bfdc127d5bd696986f96afd571a202c8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-ExtraBold.ttf", + "size": 244052, + "sha256": "2bed71aa5b210cf0ec02f002a2eb20261e16a7e39f4df066d108314016e9e4c0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-ExtraCondensed.ttf", + "size": 231024, + "sha256": "a82629ab680a43c61a489277175b4472de61b4991dca55fd211ec89082dd0c77" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-ExtraCondensedBlack.ttf", + "size": 245180, + "sha256": "f3835b7641994a5adf9bdaab8aad2e9df2f3f290ba672f9f3f8b37d79ba1e021" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-ExtraCondensedBold.ttf", + "size": 243492, + "sha256": "c0dd3f6ec3ce612324178685829be3e238efa03179d0942da8a2cb574bdc3be6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-ExtraCondensedExtraBold.ttf", + "size": 243100, + "sha256": "80fe094f6a4f2d3528cecf673f159cb6b8cec0742446b9396870bf6e83f0d0e0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-ExtraCondensedExtraLight.ttf", + "size": 220848, + "sha256": "78fc88f090c10e1ed125760cf09310c5f6c007080935a7b9e0f5285053c73b06" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-ExtraCondensedLight.ttf", + "size": 227312, + "sha256": "b2183413e2643c72d9a91b355fe523aa0f7118ac59c274a8464773b28d2a59bf" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-ExtraCondensedMedium.ttf", + "size": 239416, + "sha256": "8a3cf368098b894f4f3bc4bb83e39f85e9841374098439cf9bbd1c41d1bc536b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-ExtraCondensedSemiBold.ttf", + "size": 240632, + "sha256": "725c05350d77b3a6818f123f568120bb8112064650b2d4cd7209ba84e92d18a1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-ExtraCondensedThin.ttf", + "size": 225248, + "sha256": "b18b6dfe82ea4dda154593ce44c2a6ed1fe019c3e7a7d2a7d6666c0ee134511f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-ExtraLight.ttf", + "size": 223780, + "sha256": "676fc90038a080cf91f2d648a2215bf5c7ded83a6e8e06aa30f182af33928352" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-Light.ttf", + "size": 229872, + "sha256": "58b498460e1918d363ccd455dd5fe01d664a753c0f18603b29f8d2dbf4a63ec6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-Medium.ttf", + "size": 244580, + "sha256": "3ccc0f6f3eed219c1076657849cfb0753acb3c96f5eff0666ab1d6c58cfeee16" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-Regular.ttf", + "size": 235176, + "sha256": "b274780b69d1d23fe84b55e809a152cb2ac5306d33864b1f87622f6971871aae" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-SemiBold.ttf", + "size": 247624, + "sha256": "3046d6a938690ad7daeb1e4e1f87901d61c61b60abef6ed3fd9f5f7911890704" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-SemiCondensed.ttf", + "size": 231176, + "sha256": "c9f9e3e19bc9813e4e0fba9261903f3d9d445480a7fe2bfc7e567ba4ef5fac84" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-SemiCondensedBlack.ttf", + "size": 248528, + "sha256": "b5664c230d6d84f3b4fa6a72976b32e7e3e2d9cc7bb4e00820816835c124f6f9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-SemiCondensedBold.ttf", + "size": 242776, + "sha256": "d94e50b02e1351725366b932d6d8915e44e6ce1ca107382587f45c001b93710c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-SemiCondensedExtraBold.ttf", + "size": 242652, + "sha256": "e61a3b88f135ba8da917d74f829340a599008ebb3d4ca76a88f318c755af40c5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-SemiCondensedExtraLight.ttf", + "size": 224432, + "sha256": "c033fbdce5d876d5b5f6c85a681212fb4aacc9d49bf3e7a40e984aeec0165854" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-SemiCondensedLight.ttf", + "size": 228388, + "sha256": "35710f6af7f998923473e74eacf6c1c9a7dacd8645f0d447772532e0d59ab1d0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-SemiCondensedMedium.ttf", + "size": 241736, + "sha256": "2ce51828745a7c6ec5a6d04f2d41d09c4280031e32b5b9c800a4c2be76e24c76" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-SemiCondensedSemiBold.ttf", + "size": 241908, + "sha256": "a638069535aaec137d236c39068c85d638412eb586c76209e572386f4e64af37" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-SemiCondensedThin.ttf", + "size": 224368, + "sha256": "50e135ac602c484ca1ca4ed37ac7ccf7e96e6b995bef7d852131c258516bf4b7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTelugu-Thin.ttf", + "size": 221016, + "sha256": "e317d835bd9f7d5cef4e50b24752d3673b103898c5a74d46ceaaee0e82bbd1e6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-Black.ttf", + "size": 248368, + "sha256": "92c24b4e960ae96922eff2e219c9b9be225bac985784510422407c534ef5dbae" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-Bold.ttf", + "size": 244516, + "sha256": "110e58cb2f57f1e8af3b24f55d77a0dba27aeafd39315a3d61e5468ae9f68b38" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-Condensed.ttf", + "size": 204952, + "sha256": "9a349ae1e6086f0c989a478686d63151bb0fbe5b300da749bbde3393ae5c8ee4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-CondensedBlack.ttf", + "size": 219696, + "sha256": "020b6e404703af07d6132293ba28ea410b683a567fa34849bb1e837decf17709" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-CondensedBold.ttf", + "size": 217748, + "sha256": "c5b396274b5799b84477ca7ddc29201c29417cbe86b227dd104fb410ee37784e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-CondensedExtraBold.ttf", + "size": 217356, + "sha256": "ae86384fe9a43415ff1beea9b41089c9d90d0869b74823b34fd115dec5d49bbe" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-CondensedExtraLight.ttf", + "size": 222676, + "sha256": "3dd4501aa3b3d8ded910fda9254cf6c797aeae3a7a10d6a3e2e9e1e572f595d1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-CondensedLight.ttf", + "size": 229088, + "sha256": "bb98d419dd8d1a8ffee0b032d09b3ae647bfeeb985519e415e29328497b81325" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-CondensedMedium.ttf", + "size": 214720, + "sha256": "ad4825469cd1e413e8e220500a6af6c342fbb955042a67e4d0e4ef37ec28e435" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-CondensedSemiBold.ttf", + "size": 216612, + "sha256": "4cc42265edef248ab793384e5134839ddbfe4d503ec4bc689e5cb489f9a11769" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-CondensedThin.ttf", + "size": 230924, + "sha256": "74ba73467fe10acf27e3cbf873727b7007f03f0863bf8f13bc702aa07a925b04" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-ExtraBold.ttf", + "size": 245328, + "sha256": "c70f6e70c3fd76d66e099668313f05a21d2f655bc36b38bff86d0fa70546d540" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-ExtraCondensed.ttf", + "size": 206864, + "sha256": "ed042f06d8e57e00057a1444d26f462d140ac77bce336b13520dbcf4d0a02758" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-ExtraCondensedBlack.ttf", + "size": 219664, + "sha256": "6b34db49b2c92cc4665bc6d57126be39816d3c2ec024393b0c8ddeadeee10723" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-ExtraCondensedBold.ttf", + "size": 217380, + "sha256": "f49d0db7533dfc4b44f6e56b67ebb24ecd3af946dfb484ad95db886b93728280" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-ExtraCondensedExtraBold.ttf", + "size": 217364, + "sha256": "79124e20a616595290bb50a206d3039bf6cbf1694931e74ec2a470bd55cfee44" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-ExtraCondensedExtraLight.ttf", + "size": 197852, + "sha256": "8cbc765ee9d5439538c19f96bb01b3b99279c87566058cadd70e3daf07b5889d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-ExtraCondensedLight.ttf", + "size": 203720, + "sha256": "14182c6697a16c46c0759a7c7c4b24bbdc5f77475a48c7c5fc788e7a2d99ba17" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-ExtraCondensedMedium.ttf", + "size": 214580, + "sha256": "2b353c390fd2139426d8e5d766383d2878db1a1d916e3606331ee8e8b0aa0241" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-ExtraCondensedSemiBold.ttf", + "size": 215248, + "sha256": "c558fa7f0118acde08d15a0854342cb96c3b866c0746b808ede0cd2dd2ff580a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-ExtraCondensedThin.ttf", + "size": 201948, + "sha256": "cc11b74f1687f8383a253e62dd874dbbf70560fe48420dae42817c0b8060a15e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-ExtraLight.ttf", + "size": 224900, + "sha256": "d9b795af3a02a0ef52767a8af59e8d2827c022107aa6300ca4edf8763f60dd1d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-Light.ttf", + "size": 231016, + "sha256": "89266b14397fdbffba4be96e0036f90ed14f1da304f886749fe12f56802faa38" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-Medium.ttf", + "size": 245696, + "sha256": "712f0e03e5d58f577770aed3450a468527bea822147821ec837eed3f3997f0ef" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-Regular.ttf", + "size": 236276, + "sha256": "245f12561e7cfe0e2dfacbff231680938d1309e55a13338123d302b7e8121aec" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-SemiBold.ttf", + "size": 248760, + "sha256": "83b6ff0f54c016346e024d9294255edf119b6c73e29a593abae2a394257a6181" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-SemiCondensed.ttf", + "size": 232740, + "sha256": "e3b3082bd31a5aa0ef8a9a3ec8e872738b38378babb7e85dd85f88ebf2346c54" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-SemiCondensedBlack.ttf", + "size": 249800, + "sha256": "02c7863ccac5e9ebc40bcc52b9e5599657078c6aa5785c67965c3461cbf59d8f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-SemiCondensedBold.ttf", + "size": 243908, + "sha256": "30b3d4ddfd9fb480f4b8c9af5076ad63e7eb6be34608caea5d5f3d5829d9ac1a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-SemiCondensedExtraBold.ttf", + "size": 243796, + "sha256": "bad8d9ac627931b976888513f6c563edde6073bf6e62bc9bbbf8b913da81cdd3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-SemiCondensedExtraLight.ttf", + "size": 225448, + "sha256": "d8acfabc4afcd8c931a37d52cf4fc09fb6e79561b625c8fc1bb89686693a22d9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-SemiCondensedLight.ttf", + "size": 229384, + "sha256": "6075906c06cb3eb4627e3c51e98224da4c627347b01dce6704f903fde14c9c5a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-SemiCondensedMedium.ttf", + "size": 242856, + "sha256": "f853ef6f5eab088d4a12a325960b4f1ef5a7ae72214ee0f7cac612131be9d196" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-SemiCondensedSemiBold.ttf", + "size": 242964, + "sha256": "e243ee6f888896298fe0561a36335533a2b5e26d60e0722b5362d91315bea529" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-SemiCondensedThin.ttf", + "size": 225520, + "sha256": "ab188a696f5467e8031e830380110b529a5ce1f68b30c5a56bfe2a0b573c9b96" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTeluguUI-Thin.ttf", + "size": 222172, + "sha256": "bfc41a8113ffc32fef395d9dbed7ebb37ea1398c4f8dec9ba54d19dffee247e1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTest-Regular.ttf", + "size": 2588, + "sha256": "70f6f780859bd7ccdb61fe01d6d1ffa9d3cc38f54758071c2ba339f96b328224" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaana-Black.ttf", + "size": 27160, + "sha256": "5e96cb1b1c218a6f64d1c3840dedc228e5e223154b459db67f5cba8b52a57686" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaana-Bold.ttf", + "size": 27000, + "sha256": "476597290f449013cb07477b1089219db333c6af9ec0e436a74139ea9b2c8536" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaana-ExtraBold.ttf", + "size": 27444, + "sha256": "99b3c2d69367da864a759cb3b3b5f7d4cdeabef57082c8baf6e3a3eb83a000ec" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaana-ExtraLight.ttf", + "size": 28276, + "sha256": "42e1dc1ad9864df70463bca2debbbf2badacb0d75de0bb981c23075fe7649898" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaana-Light.ttf", + "size": 28392, + "sha256": "d99b85f4f81e25e03a406ad11918dc6ea51572bd3e2e2da0a29da0eb951c244a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaana-Medium.ttf", + "size": 27344, + "sha256": "2d3978b17041ec2f131a541186c3b7fb6e887f7acf43723d1ccbd07328d9c68b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaana-Regular.ttf", + "size": 27308, + "sha256": "7543935bdcef770c9d3dd54222651b29040e5ec82f3bc58542392b2c7a9cbd3c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaana-SemiBold.ttf", + "size": 27320, + "sha256": "000309239298b3cf31b30d7f48a59192c159d32c26e3a06da931da6b67136eb6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaana-Thin.ttf", + "size": 27988, + "sha256": "96460b9459ea9737da8f436dfc587774c7929284a20958e8dd1b843aa1f73655" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-Black.ttf", + "size": 39128, + "sha256": "9c4b2f43f9d4a7744572112b6a7443379bac781fe193a5ae45b786bfb9308d39" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-Bold.ttf", + "size": 37824, + "sha256": "2ac6c6e8a478e23b15f76e4894af1fa2210f8f350e4e6e54aad530bec03efbfb" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-Condensed.ttf", + "size": 37468, + "sha256": "1aa3d489f8588c7cefcd55b7bf8cbce3b1f00cfd61449e5234e4608a320c3b10" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-CondensedBlack.ttf", + "size": 38984, + "sha256": "ba86dd904642072b231e1c44a09138db35f5a50a7f4e95750b863bc82bb5c848" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-CondensedBold.ttf", + "size": 37976, + "sha256": "2f8f20f76621ef8c056434fa089d50bcd6e19d82ccf7361a61c0be70f1f72524" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-CondensedExtraBold.ttf", + "size": 38868, + "sha256": "32592922a8400c9cfb41f228efef0c90d99341eced218bbd8c0daa3c584066f2" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-CondensedExtraLight.ttf", + "size": 35348, + "sha256": "9a11920b726d53e43d2145341783bfe86cc7a6749499d6b3f90306369878b400" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-CondensedLight.ttf", + "size": 35372, + "sha256": "df1f11989e106febf257016bc74092f5e40bead99695292358ce732d374eacdf" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-CondensedMedium.ttf", + "size": 37916, + "sha256": "ba0747ef44a5e71cb7ee0c832eda136c8936be519d6e7fa6ad39621f17070b47" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-CondensedSemiBold.ttf", + "size": 38368, + "sha256": "70326548602f3fb27798086e1b25b3049cc504da8b46db2a916b0a079a495a2d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-CondensedThin.ttf", + "size": 34648, + "sha256": "da99030890b68116945eab9e293cec260ca55930248f4dfef8c0c38e557db77d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-ExtraBold.ttf", + "size": 38940, + "sha256": "cbb0875722642a858a2f4d48b5670e346f315d1dc9190ead0e256250e37ecfb0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-ExtraCondensed.ttf", + "size": 37320, + "sha256": "df58a7a7140f66768564d2fc2187d821ca6a2b67c5ee86146d51e91f32af226d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-ExtraCondensedBlack.ttf", + "size": 38692, + "sha256": "ae3663a75430f6b85de20469607484a0f77fcce5dbf7cc1c534580a70e1b52b0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-ExtraCondensedBold.ttf", + "size": 37752, + "sha256": "2cbfefb5693d067f5b84f6e9a2844319e6e0e061b5b7227a6d6502ad00ffbb0f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-ExtraCondensedExtraBold.ttf", + "size": 38256, + "sha256": "ae5270c3397ee4f14c982b95c312252534267c3a65f82bbc244560209083276a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-ExtraCondensedExtraLight.ttf", + "size": 35448, + "sha256": "00a4bdae017d9c32aa910a7b955ce1b962bda170d7c72aefe3959ca1e123da04" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-ExtraCondensedLight.ttf", + "size": 35348, + "sha256": "e5b5d4b6335aafd6d44267a69f1d6feaa421a6c9c38dc216c71d0b0b133775d5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-ExtraCondensedMedium.ttf", + "size": 37928, + "sha256": "138185b907dbcee3dc0b7bba5db7fa419117997148f3173fee7e0f1bf99f0bac" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-ExtraCondensedSemiBold.ttf", + "size": 38236, + "sha256": "e22cfae742551e34fe8c9ea23f8e02cd3e531d07c40d43658a6353cd44bb7018" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-ExtraCondensedThin.ttf", + "size": 34620, + "sha256": "3fa5374dabd82cdda730f73de1e34960a68056be34f690ab8cff73e44e3fcb0b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-ExtraLight.ttf", + "size": 35028, + "sha256": "7524ab9bc42cafe3eed3ed1c1892af7e9748c3792c7319b2fac9dcd628665fb8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-Light.ttf", + "size": 35508, + "sha256": "7c80ff717c98ce61e8804a0cc9285357c24755ac2d622ce53e3df7d1c9978672" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-Medium.ttf", + "size": 38296, + "sha256": "11bc8f6cf3523241995f07225fcc7d8e25eb55064fbfa39281a5e44dcd59f5f5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-Regular.ttf", + "size": 37780, + "sha256": "61cf814eec46b294d6ea4401ac295d0cecd5207bd2331dcc5a15e7301d30ee44" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-SemiBold.ttf", + "size": 38520, + "sha256": "d19ec702c2ddc26f74ccf934342147afd8bce43eb1435fa46a06ab93e368011b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-SemiCondensed.ttf", + "size": 37520, + "sha256": "3fb3c7afc952094fae5ac2b84150a5978269daeb98fdc55a431188869cba3cd7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-SemiCondensedBlack.ttf", + "size": 39460, + "sha256": "e44fc860ce55dcf05b2c97f164025140819f15199cd25a0b8bd3958a2ee33be3" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-SemiCondensedBold.ttf", + "size": 37712, + "sha256": "91a419be1cecf95959c176255b686d6255cc61c646db23a506d65e656458d160" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-SemiCondensedExtraBold.ttf", + "size": 38840, + "sha256": "564f1abd925df22dff8d1a696edba91cc96c77ff984c1d5eae96f4f0ca2dc054" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-SemiCondensedExtraLight.ttf", + "size": 35640, + "sha256": "505fd2ea2e53ddced7e36671d605ab46c1d93ef0dee68291492815904990afbf" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-SemiCondensedLight.ttf", + "size": 35648, + "sha256": "03b34a733cb28b9d7614e66b1e5f36d2e92fcb1460153669a901317b3d99366a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-SemiCondensedMedium.ttf", + "size": 38276, + "sha256": "27dcbc6e93a4d87ed5214219283f0469b454f89291b2cc706a04caf230967f7d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-SemiCondensedSemiBold.ttf", + "size": 38304, + "sha256": "c5648dc2eda1bad77a477e5404af9366da9d02696d900017e0c3dac955da36a7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-SemiCondensedThin.ttf", + "size": 34700, + "sha256": "6669ae21386def9563f80abde5dc7d61159d57a8d431fbee80dad1d2771f0861" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThai-Thin.ttf", + "size": 34812, + "sha256": "6aa7bc5379ae3c5f2bcc62ef455cbb01f336c86b5919650e5961d930d4fdc078" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-Black.ttf", + "size": 59180, + "sha256": "531b9a0e9555d920112f48aac50dcb0e038405098231ce8b3c7ceac484307a2d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-Bold.ttf", + "size": 56252, + "sha256": "84eefeb15b0b3e11e27fcc1ee8d1d18fd48997bf7416b6c66671c26b1fd436ff" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-Condensed.ttf", + "size": 53844, + "sha256": "ac080e809db3eac7f4761a2786325b49e89abf27d1ca4863166477bd294afcd1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-CondensedBlack.ttf", + "size": 58296, + "sha256": "543d3456477f1641e63c5be5b2221f0de498247607fa2316846ed713d0e0f5ca" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-CondensedBold.ttf", + "size": 55984, + "sha256": "48685dcc3e88e95142546e1025ca9be8a49bcdb480df541a3bef16331f338744" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-CondensedExtraBold.ttf", + "size": 56804, + "sha256": "25a43cf6016f2a4c198def2f95cfae4e52ac39b784ba5c0e57bc2650d85fae77" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-CondensedExtraLight.ttf", + "size": 63164, + "sha256": "ef157c1e5c0b6f7c08769ad88f5cb20f194b779d459d60a40780056877e740f0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-CondensedLight.ttf", + "size": 55984, + "sha256": "ea701076fa791896380ffdc7960876fce4a55c0ec1b0d6a8a65a4f9b097bf1d4" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-CondensedMedium.ttf", + "size": 53832, + "sha256": "b1c395d6c45a81721d4621537362aa8c1ee0e5c01cd6510645a76e6ec397321b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-CondensedSemiBold.ttf", + "size": 55020, + "sha256": "593364b469285e1809541ae4996303d2c0e1903ffab2b19ce4276058a40a0336" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-CondensedThin.ttf", + "size": 51628, + "sha256": "e3d16e532433e97055e6865aa2e171b339fa99509a65f53a56043ece80d8aa22" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-ExtraBold.ttf", + "size": 56908, + "sha256": "b9853f12ca281db84a35cfde1fb5eff5273de62848b1aa452ccf653b83b68578" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-ExtraCondensed.ttf", + "size": 53492, + "sha256": "1580c7b4328f807d5cc6b9fb2a271460a2e5d263798e95b7478a63f3c5a6cd75" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-ExtraCondensedBlack.ttf", + "size": 58100, + "sha256": "ca0107a8b4496d227c2ec068efcb558c7c49d50e8007704c79550fc923878f2b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-ExtraCondensedBold.ttf", + "size": 55504, + "sha256": "102d106f8f8e82bafa5cf25d6376230babd1e63534ff5a74cab00f6cc9e2c74a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-ExtraCondensedExtraBold.ttf", + "size": 56240, + "sha256": "658ef5c703c68c9e53d5410e9092ffe42a75275ff222205b8df0a7ad6837ae07" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-ExtraCondensedExtraLight.ttf", + "size": 63328, + "sha256": "cd12435e9fcf3ab46fd5a1d204ee79aa463404ff0646e1bbe386ead5014d767f" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-ExtraCondensedLight.ttf", + "size": 55544, + "sha256": "ce9d37bf787af6f9364e5247676dcc655e1992fd4b581679d069559a80066a15" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-ExtraCondensedMedium.ttf", + "size": 54072, + "sha256": "cc0ebde9f0d5e5bebd35778be09c7522c1fc390fd5277551d8a3430ccdfdd096" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-ExtraCondensedSemiBold.ttf", + "size": 54532, + "sha256": "f7bf295c0fbadba0d0b9630b62351102b4ec6a09eb085fccfec4ebb90b068dfd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-ExtraCondensedThin.ttf", + "size": 50168, + "sha256": "230e1cab0938a412bae5c18b8d1cab198b7f7688edf1e74e1856fd30553bf5e8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-ExtraLight.ttf", + "size": 62664, + "sha256": "8d3218f052ec1ee040383c061d1b02483906a894973827dc671d5a1043f4d7d9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-Light.ttf", + "size": 57040, + "sha256": "6a8a589a343ab7a10d8704f4ca7874e6a559b5cba803310efbfa4d766d7f907b" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-Medium.ttf", + "size": 54272, + "sha256": "ae7b4ffe95b8007ad1ce4273892709c5a335a2ffcc376ae3f07f326d4f822400" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-Regular.ttf", + "size": 54180, + "sha256": "9726ca7f61a25fbbb6ed0e37a8aa2f20f59a4b06303f9de2cceb6a523c8d0a7d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-SemiBold.ttf", + "size": 55136, + "sha256": "ef31143f856bbe51319daf4a3928585c27039ca4a9cbdf2db123a7c6d7ea0164" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-SemiCondensed.ttf", + "size": 53496, + "sha256": "c7109c33fd6bb9c11321862177bcfeb3224b2513e0241a87d21b9046a70313a6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-SemiCondensedBlack.ttf", + "size": 58720, + "sha256": "b52d5419822d9f4e879934558c4c75e64e2069feb185afd43c94c3ba8b8fe0b1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-SemiCondensedBold.ttf", + "size": 55928, + "sha256": "947a833651201daf5fbb6d2e82479003474b647c371bb701a0a44f9a678585e1" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-SemiCondensedExtraBold.ttf", + "size": 56552, + "sha256": "db0b0d95c2014527bd7fa609c0bd00aba56d7a5ffd5169a856128924485deae6" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-SemiCondensedExtraLight.ttf", + "size": 62700, + "sha256": "46c20cb4ff75f4231277407e225f8a34952591440dc3b50e9f9d90d34676db3e" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-SemiCondensedLight.ttf", + "size": 56900, + "sha256": "3d33e4bfd594f2b52c5b6c8e82d358bded74a1002fd4e121cec28e8269d55962" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-SemiCondensedMedium.ttf", + "size": 53780, + "sha256": "ca0838bb669b87a2a04302fe737adbac04c57edd7578d435535c543449d5d96c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-SemiCondensedSemiBold.ttf", + "size": 54960, + "sha256": "1afac8c9089e5b6268f2d5840ebb4d8cb6f1e74bcc53eca9ffa1ba62a3e02ff8" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-SemiCondensedThin.ttf", + "size": 52212, + "sha256": "96009c5f785997755004cf24d07edb02076908d8d0956d348e561152170119fe" + }, + { + "path": "/usr/share/fonts/noto/NotoSansThaiLooped-Thin.ttf", + "size": 51816, + "sha256": "6b24a74bda8338a272313a5505e7a19f37b6a621e510bdede4b44484166107c0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTifinagh-Regular.ttf", + "size": 79376, + "sha256": "8058054786bc572007193988654d1ded342bc2538c2d91c50d1a7238ea1a99cc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTifinaghAPT-Regular.ttf", + "size": 79372, + "sha256": "3a024280a937fd6371ed191ae718c1bcfd5379036e7e9048aab4cb5dcdfefdd7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTifinaghAdrar-Regular.ttf", + "size": 79000, + "sha256": "0c07dd9372f3d542175e3eda76215977f0f8bcdfcd24dd5f3c634bbfac535bdc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTifinaghAgrawImazighen-Regular.ttf", + "size": 79232, + "sha256": "c1750eaaf59d6336971193d1b4d69970e1c5a8c3c079ce2f739f0d1bdff3aa6d" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTifinaghAhaggar-Regular.ttf", + "size": 79508, + "sha256": "a4864c1eb95707283eee5dbba64bf6b99edd1011c2b8a9e51c7a1d1bf941ba03" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTifinaghAir-Regular.ttf", + "size": 79560, + "sha256": "32b8789d0d47725f8b71824f0ebb393c401f0481fa31906e34b5adf0acda89cd" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTifinaghAzawagh-Regular.ttf", + "size": 79660, + "sha256": "1fce773308ca5eb9132fd66323feaef0b034ab2b3f63275e93c90ce637d3db44" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTifinaghGhat-Regular.ttf", + "size": 79016, + "sha256": "c06c851cbed87d8281688b04d02f26c9d70190816ef30123790553eb8e8e3fa0" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTifinaghHawad-Regular.ttf", + "size": 79184, + "sha256": "88cf22f57c3c99964e88cc2b16eccbd8d60dd7e61cbb49fc6944779c7cd17298" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTifinaghRhissaIxa-Regular.ttf", + "size": 79308, + "sha256": "3b809a1556fead28de406d878632ea89fa474c4488c41ae7354f7f8b5fa5d719" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTifinaghSIL-Regular.ttf", + "size": 79620, + "sha256": "788740f9e2ca1ea75b7351ce4ccac6d583377adbba98740e6ab05ffaf3507fdc" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTifinaghTawellemmet-Regular.ttf", + "size": 79632, + "sha256": "8a5f9889d655290a22740bc2c02a7fbbf6a7ab2d5c8de119f76e8644bd8dcff7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansTirhuta-Regular.ttf", + "size": 96848, + "sha256": "f7a0d9fa2557ebebbacdc8888556e217c298eb049db6ff6dc210e916a1d9e860" + }, + { + "path": "/usr/share/fonts/noto/NotoSansUgaritic-Regular.ttf", + "size": 7016, + "sha256": "cd372bfa7aaa1d008786ee8097a1e7c47ae8e5c2e23c5dd3343568409c5a7e5c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansVai-Regular.ttf", + "size": 91252, + "sha256": "3b454157b5fc64cc21fb9c76cc86390997b1540499906179a1d1375e59d1bf33" + }, + { + "path": "/usr/share/fonts/noto/NotoSansVithkuqi-Bold.ttf", + "size": 18224, + "sha256": "cb5c4d0d51d7dccc9248cd257f5f3034fe5db5d54f4b3a7fbfab1ceda248c04c" + }, + { + "path": "/usr/share/fonts/noto/NotoSansVithkuqi-Medium.ttf", + "size": 18348, + "sha256": "e1aba1aac128c143f9e131c98641fceae0e3657044e83f602459564299962548" + }, + { + "path": "/usr/share/fonts/noto/NotoSansVithkuqi-Regular.ttf", + "size": 18548, + "sha256": "2dae2597193a8e92d2b1dc28fd8eb5e01a6e738e579eea74595bd1775ae6a129" + }, + { + "path": "/usr/share/fonts/noto/NotoSansVithkuqi-SemiBold.ttf", + "size": 18300, + "sha256": "068cda3f2716dcdd5771c08111019ce07f9e5f7503f94bec0ded929ee9dc32e7" + }, + { + "path": "/usr/share/fonts/noto/NotoSansWancho-Regular.ttf", + "size": 18448, + "sha256": "fb2e48d59128cdea1cee0ba24373bd5ff085a72cf98e53bc1674770c8ac67d9a" + }, + { + "path": "/usr/share/fonts/noto/NotoSansWarangCiti-Regular.ttf", + "size": 27264, + "sha256": "f992ab2ede41083879ac42480d360807106afcd060f5df77ae73f79a26067fa5" + }, + { + "path": "/usr/share/fonts/noto/NotoSansYi-Regular.ttf", + "size": 183036, + "sha256": "9d5d3f9912f14bee3f32c9d8add8a5dd02910dcabc00e3702a2b9883556852c9" + }, + { + "path": "/usr/share/fonts/noto/NotoSansZanabazarSquare-Regular.ttf", + "size": 20544, + "sha256": "4a42d77e15f8f6a295a682369fb941afca2c494bd4f4341882d5562f1ed5ed23" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-Black.ttf", + "size": 787868, + "sha256": "fe00a6442f96ca0ab8de868ed837a26810a984f1389298430133d22c91790230" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-BlackItalic.ttf", + "size": 802928, + "sha256": "443e4b1f493cd67c97ddaba43d4e5c77871ab762bdc28a054328992d86cd3de3" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-Bold.ttf", + "size": 747144, + "sha256": "96656aa5cec8f1d6fd0e804c1fad397e1a1cfa082e6642124e0bda68cd8363ce" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-BoldItalic.ttf", + "size": 779236, + "sha256": "c710c5b9cf354ae46e7a10472a08019b28220fafeb5887a482a76856f8f6fc0b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-Condensed.ttf", + "size": 720244, + "sha256": "d0434e2eeb467172a723184911ea4d6d33292e6c1578af591859064ff458ec4d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-CondensedBlack.ttf", + "size": 759280, + "sha256": "3e9500a32e0cec131f437708858f921bc48caf3c0996917bbd1c09a0fc7303c9" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-CondensedBlackItalic.ttf", + "size": 787904, + "sha256": "ef17eb50dc7f78890c2b90c3e7848a79e062b31333bc4aca15b6538f43445fd3" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-CondensedBold.ttf", + "size": 756268, + "sha256": "47afe98be11bfa3ed97521f421624f9c4390fc7ef7d3a6f84caec0674ada5484" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-CondensedBoldItalic.ttf", + "size": 784548, + "sha256": "3e0a7ea34b5d34f5c101075aa9f45c59ad420086ac75c6525180475a09b3c490" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-CondensedExtraBold.ttf", + "size": 757912, + "sha256": "7b076fff91ff11c70afc058e1d049efca89b94f681ec4f506df27394325fb47d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-CondensedExtraBoldItalic.ttf", + "size": 785940, + "sha256": "d8214a0206f0e7b4f0e9acf66a056cf7e1f7a65289b7ed23f0fd98f030c6c372" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-CondensedExtraLight.ttf", + "size": 720024, + "sha256": "fbde651a5d4fbc99129e1e91a1f15f03f3bf165a890bdc43b637890837a3b31c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-CondensedExtraLightItalic.ttf", + "size": 733584, + "sha256": "7c0b4f49385f0f4b722d1ad6971dddf1d29b2414b3237f573add169076acff38" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-CondensedItalic.ttf", + "size": 749968, + "sha256": "ad5cb8045b0f15f39e21e8e24ec46dd7aebcfd70e6deffe9ad090be318e0c9f7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-CondensedLight.ttf", + "size": 711328, + "sha256": "a2461a6474e6dfe99581a205a7b4bdab8048bbf3c44300e5f5f475ac909574b8" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-CondensedLightItalic.ttf", + "size": 738900, + "sha256": "4704536146b81cf4cb5553a433df89c84e8a4d2971454b66a2886530eb964043" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-CondensedMedium.ttf", + "size": 719864, + "sha256": "2c9751cbb559936487144ac4603234ffafc4eb482f25fc3cad6dace2232c6dd2" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-CondensedMediumItalic.ttf", + "size": 759056, + "sha256": "ca274f667556912afbc10251a9401e1165ade558c93dcc84b8a7c10cc3163ccc" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-CondensedSemiBold.ttf", + "size": 738148, + "sha256": "4ee5faf08ae10ffd68fc16c7e408cee5709033ed6a563e3040619397d5c11d68" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-CondensedSemiBoldItalic.ttf", + "size": 774624, + "sha256": "4419ab754107fed6033ebf0b3f29819781796068ebeca99620fcecc6d6ffacc3" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-CondensedThin.ttf", + "size": 747752, + "sha256": "23ab1526ed957ab0b48b83e874a3a50be459867e051dda4fd6e9020c8078f205" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-CondensedThinItalic.ttf", + "size": 740568, + "sha256": "cba68d85d4cd78bda146e01672188ed2f0b67ad573301b3752d5a565fa945fdb" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraBold.ttf", + "size": 757468, + "sha256": "4a37007cde6e82710dd23b0ae86e58e225ab4ea1c3a79b36cfa5a184500e5b01" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraBoldItalic.ttf", + "size": 785404, + "sha256": "148b2a49a5ed4d28c541b4f066e4e5ffadb9e90423bc775dc827a299c248db16" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraCondensed.ttf", + "size": 703564, + "sha256": "3b5a405e821ba1b86c236ec2f539dfab5ddb080409102b5d8b0d4622d52bafa5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraCondensedBlack.ttf", + "size": 741924, + "sha256": "3e4eeef02000bcc641369e062dcf85f463d6defd3ee436176cd1dd6466408c20" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraCondensedBlackItalic.ttf", + "size": 787476, + "sha256": "9bf32d2c784a30b00f6554b984be667300ae527088704c8d51a847def7e0bedb" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraCondensedBold.ttf", + "size": 746148, + "sha256": "c2b2c0ae165cbd074007dbc86effa1f21e528e6c8744691ddaee99ffe387f995" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraCondensedBoldItalic.ttf", + "size": 786584, + "sha256": "d8067c0f21571c16d28e447e4cbae70c62e5af3de29494ae5eef6a4f1ce98df9" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraCondensedExtraBold.ttf", + "size": 742408, + "sha256": "56fb4a6a69b466a9f24c3a9e98e8e2a541383c7d32b4cb2dbfb37bb6a00a8341" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraCondensedExtraBoldItalic.ttf", + "size": 784392, + "sha256": "eb7c7d3030f4de02fa94663d42fc3617af8bfa8d105916e7977afbc4869f4c2f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraCondensedExtraLight.ttf", + "size": 705908, + "sha256": "e7404ccaec5d369fb0f45d13fa1dc2f0794c184f8e301b988a02b18d0d64351e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraCondensedExtraLightItalic.ttf", + "size": 733788, + "sha256": "786035be4bcac3f90ecbf53e0eae49cf32e436158e4fb0a41e5ac825fba7f9cc" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraCondensedItalic.ttf", + "size": 748600, + "sha256": "65a01288bd7157dae5cb3923493e0c2d8fdf5c6488746d6e1f3a6295d13aef68" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraCondensedLight.ttf", + "size": 696940, + "sha256": "92df91f123c332b94d6f46bcd58f7e4efb5c2d4f11e8af10c75b1a9f24966fad" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraCondensedLightItalic.ttf", + "size": 736752, + "sha256": "69ca046ae5159772b0e70b2a2a0bb39596f3a492f464b170700bab6a458d1a65" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraCondensedMedium.ttf", + "size": 712176, + "sha256": "97f106d7c827d93abd0dd49059134e0dd63f0420fae27d345ffd6c5eb10160e6" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraCondensedMediumItalic.ttf", + "size": 757784, + "sha256": "15d7a23d87b6565c93c10523c93831a459e7ebab9ea57c08562b0425ca9d624d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraCondensedSemiBold.ttf", + "size": 724648, + "sha256": "2cf8157550c3b39062083f99f8b21c9a4dc2173537ec5cd70509fea525937f47" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraCondensedSemiBoldItalic.ttf", + "size": 779856, + "sha256": "d66f7bf556d7796691a584e41dafc896574527dff39bcfecb9af0b034ed768e9" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraCondensedThin.ttf", + "size": 732700, + "sha256": "f0af3ad80646c853434d4df2dbac05c4d4b9661e354abfeca3d0e08fb82fb23f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraCondensedThinItalic.ttf", + "size": 748244, + "sha256": "96a392a85919c87f9e0f8df230e0f2fbcad9b5d651c3a641af7e596e5bff5d1b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraLight.ttf", + "size": 713292, + "sha256": "c748f04e98ea25ef52ed74d2a6c7ea71295f8f7fdcbfb7c2c367e53703f400e8" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ExtraLightItalic.ttf", + "size": 731468, + "sha256": "32c0746d60c397b6acd81abedb181445e564cae32eea986dcf1fa2ceb3a2c933" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-Italic.ttf", + "size": 756796, + "sha256": "749e80e313ef711f9373c6cce17c72297ef05490b3dcda7967d1d5d90bf1183f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-Light.ttf", + "size": 721944, + "sha256": "0f3960036f3f9c3c88f813ac9c51681645c21df60aadaf4a34a4767a2e8aadbd" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-LightItalic.ttf", + "size": 739244, + "sha256": "320bf4124bb3f702602265aa543ceada4e9de54b4dfd00e84382c1febae91e94" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-Medium.ttf", + "size": 732392, + "sha256": "afed2854f24565479063b854b22a21ec88673d9413fd3eb7a5b2a3b00f4dd64b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-MediumItalic.ttf", + "size": 775528, + "sha256": "d7a129a1d44c0a597fc36ce5b2ea6a98503bd2277642e946d25ffdf5520991ec" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-Regular.ttf", + "size": 712444, + "sha256": "19e72cd8d595fae5bd74a5206f5d938512e1183d4fed7abb1ec1be1d7efa5f88" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-SemiBold.ttf", + "size": 739428, + "sha256": "8a344d65ba56c58991ec6c3b40faf73af4e73ecd3e1db624ea640292d98b5bb6" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-SemiBoldItalic.ttf", + "size": 775472, + "sha256": "233e5aaf2a2c60c17ed8ac6ac3178ee7d293f00dca4bbe8f9127d29996e56bfa" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-SemiCondensed.ttf", + "size": 723680, + "sha256": "443a968c16fad3ab52e0d190321696b69a8bffd5eb4426301cda784f0f30312f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-SemiCondensedBlack.ttf", + "size": 787428, + "sha256": "bfe5e959ef999864aef661976759050ca389b3a43038f2a5d1d577c6da16d274" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-SemiCondensedBlackItalic.ttf", + "size": 804324, + "sha256": "e9d8363f72d48977c6c88bb2741689e077c27ae67ff37e919cf255f5efc22cf2" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-SemiCondensedBold.ttf", + "size": 750888, + "sha256": "db2b3090d8984e8711c0b1aeea6f95cd839fa1437bd24e5a796660d2772e9727" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-SemiCondensedBoldItalic.ttf", + "size": 783228, + "sha256": "15ee57f7b3a5a0557692f26a7f4247b6e10a782a3fee18c5a5a79957598a136b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-SemiCondensedExtraBold.ttf", + "size": 759404, + "sha256": "886734117d0553e7c9fb8872c81de2fdf151eddce2b094ac7d103464f49523f7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-SemiCondensedExtraBoldItalic.ttf", + "size": 789308, + "sha256": "671065e6b5da4005472e7fa4c067459c34c52414a3976c9c0dd4667f19ae2c95" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-SemiCondensedExtraLight.ttf", + "size": 715072, + "sha256": "1728e657afcfc9838d48c89257503177b2f2382d8feacb85ad75b87fa294d3cc" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-SemiCondensedExtraLightItalic.ttf", + "size": 733264, + "sha256": "46ca28ab2bb1d70ebfc98729e3dde7cebb2f99f55f5835a513fdae9b0f889751" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-SemiCondensedItalic.ttf", + "size": 757172, + "sha256": "fa62a45bc5af1f40828f911d52afbd34c20da5ac2059e09518cf64cde2246368" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-SemiCondensedLight.ttf", + "size": 724248, + "sha256": "a831356b9676798d2396527fa4153a76b1bf51177714ec2f741d7e95d4a9dbf1" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-SemiCondensedLightItalic.ttf", + "size": 740160, + "sha256": "cd09aebf9fed47ff13a55d77c6f9275dfd263a92c61e2346076c8bf7bbaa0481" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-SemiCondensedMedium.ttf", + "size": 737836, + "sha256": "4c20e89ae447066de11239291ae5d069b23880e3280391638cb5cdcff1cadcaf" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-SemiCondensedMediumItalic.ttf", + "size": 770580, + "sha256": "3162f42d6086e3e3e10c912e5fa4812cd4f63c1fcfca2dce32c42e80b098ee8d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-SemiCondensedSemiBold.ttf", + "size": 741960, + "sha256": "b703127a9834e593aa7370923526574aa78bb5b112ac65e531aa7bca9398ab5d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-SemiCondensedSemiBoldItalic.ttf", + "size": 775396, + "sha256": "26918702e06dcc73657e77226962861af7d3c459a56a624916edb153fb027148" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-SemiCondensedThin.ttf", + "size": 746928, + "sha256": "6d5eae73ffdddb3e82967d33f0bdec4112ec89ae2f64eecfba9ceaed82b4de7f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-SemiCondensedThinItalic.ttf", + "size": 742512, + "sha256": "cda6e584ad66df7f3c5692d8336a454c068f36c410b5ac5881071170a3a2a2af" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-Thin.ttf", + "size": 755308, + "sha256": "29e2e93b39405da649e93cdab0dc5ac0787f19447260821ff2da71d20caaa5f9" + }, + { + "path": "/usr/share/fonts/noto/NotoSerif-ThinItalic.ttf", + "size": 749824, + "sha256": "bae29f58b7aee59109444960327e42c8b65031e5a627c6781f3dbdb2dffd7c16" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifAhom-Regular.ttf", + "size": 21212, + "sha256": "8153650d7fc1362a40b6cb355291e1d2718d82835790cd542a2521cba53c8fd4" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-Black.ttf", + "size": 32340, + "sha256": "ce52754bc0096b968ff62984fd0b1edd858303a2fa09a94c6f435c58705625e1" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-Bold.ttf", + "size": 30260, + "sha256": "79b6e9d36f8409444d16bb88e8c1f091aaf9548fb9ba7e5dba572aec325f905e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-Condensed.ttf", + "size": 33640, + "sha256": "7cc9f4c3525059b5143fb5173e92f6d8612f5a59fa9271c539b7357939979d27" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-CondensedBlack.ttf", + "size": 34852, + "sha256": "724beb511351c5599a16a467046151a4fa86dbd9a0c998683f94a3ff4dc397b7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-CondensedBold.ttf", + "size": 29800, + "sha256": "6a5c257f0a520381939bd483d68bc2053486d65daf85d2446c51a6344ce591f4" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-CondensedExtraBold.ttf", + "size": 30856, + "sha256": "7fe37ce2e8d7eb30c5e53bf25059b4a1fc851321c5ae61b14b6f7ff044471274" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-CondensedExtraLight.ttf", + "size": 30936, + "sha256": "c9c1678355c9821d61f5e84ded63a9e9aeea78099d814058760ad79470ccfe57" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-CondensedLight.ttf", + "size": 30472, + "sha256": "f23bd6e75213b5bdb7211dfd3de2bcc2d49c3b5a7c0a719ee3ba7622fc1d6787" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-CondensedMedium.ttf", + "size": 31248, + "sha256": "4d9b8a01c4db65a897ab1bba7d41f6972a2d7aa431de37233afadf2f775aada5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-CondensedSemiBold.ttf", + "size": 30668, + "sha256": "962dbfa79fee054e2dd74ee651ca6d7781c52dd0c42a5bbcca232416057ab4c7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-CondensedThin.ttf", + "size": 32180, + "sha256": "fb6180f342c734a6cb3180e4b88e4ad32f4355b30218e0719e99b79734d5b2e6" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-ExtraBold.ttf", + "size": 34516, + "sha256": "b6443e89074ac6dbae139313cd09874343fa5f551cc2bbbf22542d5033ed330c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-ExtraCondensed.ttf", + "size": 32756, + "sha256": "a7f513679425f225f4b418925a8bcb96448112a987025c64b17d64fbf98d49a8" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-ExtraCondensedBlack.ttf", + "size": 34788, + "sha256": "2ff1e09e0e53a33488a454a7f2334d3739e0db796c68f9332804548383e2df8c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-ExtraCondensedBold.ttf", + "size": 29800, + "sha256": "f490b58292c26812130eada7d3cf84d0a3b85c5cecd9ae1bad8c7496c262d983" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-ExtraCondensedExtraBold.ttf", + "size": 30644, + "sha256": "5d46099e5808a9ea56e624f69df1732c1e42f7720794be241cdf2baf6337473e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-ExtraCondensedExtraLight.ttf", + "size": 30936, + "sha256": "684c5ebb08f973c6418db60f342b8ac735fdcbce89acd1e70cab9c10d5ba56e3" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-ExtraCondensedLight.ttf", + "size": 30572, + "sha256": "d74cccba92bf0250cd168356d4d92571a6e1bdcd9d202eb3cf8a970b2a6b4850" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-ExtraCondensedMedium.ttf", + "size": 30180, + "sha256": "30eee831cf817d353e1e1913bc5100e9cb183f54c52bd7671c853fe7ca982ffe" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-ExtraCondensedSemiBold.ttf", + "size": 29736, + "sha256": "35563fccbea15d677d32a63658dd54e8876a6772883ef5da57ff0e15e62e6e7c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-ExtraCondensedThin.ttf", + "size": 29056, + "sha256": "149cad19ccbcfc8ad5d185111777988fa80a5d85ae66bca726e1240b5d19c960" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-ExtraLight.ttf", + "size": 30716, + "sha256": "e39600060c2701fe97749dccf4f7ee955f4c0e3d8d8929e094fc4ee22905253a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-Light.ttf", + "size": 30748, + "sha256": "0d5441710947413428eb2db4c44ec8ced82f55b4ff803fc08bf2a5f3dc4ddbd9" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-Medium.ttf", + "size": 31656, + "sha256": "e882020eec686e496984bb3ac316f7654893031ec4218e3d815d263f5bb49bf0" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-Regular.ttf", + "size": 31040, + "sha256": "6dc71b17c55d398c7cc88edeec527292ba1e0573b033ac2136c636ab36c27d76" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-SemiBold.ttf", + "size": 30880, + "sha256": "25505719552436e6e7a3d77e3f4c98bf66189a4832c1ab298a6835b78e01059b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-SemiCondensed.ttf", + "size": 33392, + "sha256": "33f766b2bce2006d19b4cbdee48baa9f04e6eca572c91e77a0a5e8e1ff448ab9" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-SemiCondensedBlack.ttf", + "size": 32308, + "sha256": "94ebb2b00d7f78cfd36bdd093e4f7895ac8ae57bbcffdc32af4a4cb09df3577d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-SemiCondensedBold.ttf", + "size": 30068, + "sha256": "25c1fc7052b6bad62db70115a7930ba81075b526fc5244d2313ac58a777254b5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-SemiCondensedExtraBold.ttf", + "size": 34756, + "sha256": "f204d46da7acb9347887f9c7d5615c631afe66a0ac91b95eab593372479e6296" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-SemiCondensedExtraLight.ttf", + "size": 30724, + "sha256": "ffade45eef1d66da6b6ffe60520aaedbd93cd890bd0ccc14fe6e35308cf3525d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-SemiCondensedLight.ttf", + "size": 30960, + "sha256": "19cd957f01ee4debd6a8aaaa59277c54195237ff27a2f56422de486ece1d6b1f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-SemiCondensedMedium.ttf", + "size": 30792, + "sha256": "9e24ede68b957d05a4061936338eb0d3b274c1b47e14663441c72ac6450e8832" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-SemiCondensedSemiBold.ttf", + "size": 31796, + "sha256": "7c01741039d95b42f1b52f710bc595a404e242fd620af42af02613125ef2b6c6" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-SemiCondensedThin.ttf", + "size": 32128, + "sha256": "6b4e181959f8735fcb3048c4c94a356d471d862fd898b78c76250856c7995566" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifArmenian-Thin.ttf", + "size": 30504, + "sha256": "20480721d37055c2a859f789c351730f57696ab93cc0c2af11b7d7724ea22d89" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBalinese-Regular.ttf", + "size": 53872, + "sha256": "8282b492a229638518fc586cf55cfe3751ca68f61feb56c89d6bff48607719eb" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-Black.ttf", + "size": 332180, + "sha256": "ae84b8c58274ebf186c104a9470a3a42533acde637d625fc51755c5cd6aa7c92" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-Bold.ttf", + "size": 317612, + "sha256": "7a5a625899a77187518b46a9c9c174671a8b740cdbd88cd2d01766598e8cf6f2" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-Condensed.ttf", + "size": 302980, + "sha256": "e81e66ad1ed93da8397570b137acdd7be5d4405e5cab52d3c378048e29dd14ac" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-CondensedBlack.ttf", + "size": 319760, + "sha256": "b548c10de146a21a04ce5c7db40d5ad45500e360188584688fc8846e67155dda" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-CondensedBold.ttf", + "size": 313264, + "sha256": "639ef88f150d7fb41bd6445951db3a7eb7d328ee07eaea89c0017d04011807c7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-CondensedExtraBold.ttf", + "size": 318276, + "sha256": "bdabb56d0d6f0becd8a0914807cfaf29f6e95bfc52898371137cea8042cf2192" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-CondensedExtraLight.ttf", + "size": 269028, + "sha256": "20b476a7a69358485a63b69a01cbaf9792c78b48286bce57d4bea62526a690d3" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-CondensedLight.ttf", + "size": 291720, + "sha256": "73d0bbbe289271b9bcbe7738266cecc051a32b199ac444f0e4bd61848798deef" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-CondensedMedium.ttf", + "size": 304724, + "sha256": "9ffa2e10e844223bf97347b2b0749635af216d797760fbeacb783b876a5ba2bc" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-CondensedSemiBold.ttf", + "size": 310296, + "sha256": "9fc328ae8a966cef64b66d382574d53daae270b59051d7d539152c5d98c1679b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-CondensedThin.ttf", + "size": 276296, + "sha256": "35aa2d518271234bbe894d6cf01c57aa4cf84ac3def3e1e6dbd1b3164231b0a0" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-ExtraBold.ttf", + "size": 318424, + "sha256": "1aac7e952492615c8a81d7de90e032990d266ed823bf3036456241094770db48" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-ExtraCondensed.ttf", + "size": 299164, + "sha256": "2b6fb1500134cc14f2e7daec10ae1f747390fe61686cf29d9cf6f661ebd34ffa" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-ExtraCondensedBlack.ttf", + "size": 312308, + "sha256": "785751399dd04f7f13c492f7c5633ca807cad58eaf20e66dfa746f220f3af775" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-ExtraCondensedBold.ttf", + "size": 313004, + "sha256": "5e0b2f454f689f10810735fb17f862fdf03b0ed63057c99d1254e97b92b1ed8d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-ExtraCondensedExtraBold.ttf", + "size": 315680, + "sha256": "7ed71245cf33ce561502e60d6ee376ccb890a7d47dec17a9e6a22e785ce688ea" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-ExtraCondensedExtraLight.ttf", + "size": 266384, + "sha256": "346d82a5bcb9278a30c0ee51c2296e8e9ae82c0249c7ef23e9e8ba84e21ff433" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-ExtraCondensedLight.ttf", + "size": 290548, + "sha256": "c1736a114e933658201c3f4d2d02528856cb14d02a67d0c577ab35f6f8c1c4ba" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-ExtraCondensedMedium.ttf", + "size": 301836, + "sha256": "65373cb3cd9e5c25f7a581b154a925591793c825d76aedb6a07cf6de4923208a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-ExtraCondensedSemiBold.ttf", + "size": 307516, + "sha256": "ef2f77096fc1440f48583d8f974b21cbe218393c8ae1b5ba802e716e6b33fb86" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-ExtraCondensedThin.ttf", + "size": 275412, + "sha256": "ea950e115feaedfec6a81233a0b903bc3876ff93006dbd0a4d80e0d329a72ea8" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-ExtraLight.ttf", + "size": 275316, + "sha256": "b8f4ac98cefa12fcc333000bdb8e8948282d0b71cad1cb6ac9dc4aa233346ef2" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-Light.ttf", + "size": 283244, + "sha256": "686dfb33951b61f3b8d54c59641083ff20b976b61826cfa2e645ae4773174102" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-Medium.ttf", + "size": 311408, + "sha256": "2f19be176b5168886cec70b110e8d6294a226e64ec02f44c7e8638d49def227a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-Regular.ttf", + "size": 324020, + "sha256": "c1743dce480147e4563e479ddc31d5ff6544372437428622a54c7c867c50f55b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-SemiBold.ttf", + "size": 312512, + "sha256": "bb9fe0664354eb17ea5f93c928c886287542bc73b000f29a325d3649c8d634ec" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-SemiCondensed.ttf", + "size": 306568, + "sha256": "1523fa67c8043345c4ccd7da1b18fb419703389402ef3e81f5f337266b01310d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-SemiCondensedBlack.ttf", + "size": 322720, + "sha256": "b74fc7c1f3c4bf602643ac33639a00e7bfcb364ee19adc6ddec89e4e51aba82d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-SemiCondensedBold.ttf", + "size": 314100, + "sha256": "c56183c91034b9d6823d7452e9edce38b42dff296b7c56122b085215545bb283" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-SemiCondensedExtraBold.ttf", + "size": 318152, + "sha256": "74010bf36798157940997d2f7e88ef23d817a70d034e4e2a17e70e3456c68283" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-SemiCondensedExtraLight.ttf", + "size": 271364, + "sha256": "68582cc3776bea3b1680a26b433c99f563ae5746d6879c972ebe57779b225009" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-SemiCondensedLight.ttf", + "size": 292672, + "sha256": "237f877502f3783759d46f69e3d8bc850a1a4eb7d50a8a65840de9b0880724e5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-SemiCondensedMedium.ttf", + "size": 305744, + "sha256": "912bc7e226628451c3371f857463d5a58d53db097b1682f7a1035f8f75fa00d4" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-SemiCondensedSemiBold.ttf", + "size": 311000, + "sha256": "fcc4ffbbc98daa437ced362b9e6e39d846aae70628f531e2af3512f35d3b6adc" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-SemiCondensedThin.ttf", + "size": 278052, + "sha256": "d7b00365023f1c1b99c8e34d6ded041b461b60c0f17b4ec55d1a92e74e2c0d28" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifBengali-Thin.ttf", + "size": 287984, + "sha256": "146779d9a3da20189ad5cc81a69ca0ae7ebe0ff702e3d4bc223641271940b0b6" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-Black.ttf", + "size": 304788, + "sha256": "b80231c0ce4cf0e364f68d1e5391fe1a84a54bb4717f97348762ef28329658a6" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-Bold.ttf", + "size": 298840, + "sha256": "0544878a7b09ea1dc16d9de1f7a1f8cf82beb255d66a287712bb05a24336bcc0" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-Condensed.ttf", + "size": 280664, + "sha256": "cdf67de5bcbf7890d144440ebb58da4234421cafb240df90294e91e41c02ebc2" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-CondensedBlack.ttf", + "size": 303656, + "sha256": "b6020814173900d9814ca70960a8980bdc6277db7b7f97d1c98fb20d4a517e90" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-CondensedBold.ttf", + "size": 297772, + "sha256": "c04c4ce8b810a1ae9d3f5ffd38759f6cf0a9a6222768d69b4a03bcae52f589cc" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-CondensedExtraBold.ttf", + "size": 298700, + "sha256": "c77d45ba4a4fb0f6267bc633e6c0333bb004d78e7ba2da2adc2bc03936e068ec" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-CondensedExtraLight.ttf", + "size": 270720, + "sha256": "892447c9e7bd884c368cdf976f3c9934e63fa6301f8878f69a3f9a5376ee6d29" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-CondensedLight.ttf", + "size": 272844, + "sha256": "698e352d8cb58489cf564cdf1f9505038b2328d8a4c20d4c3555d83df53e0bf3" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-CondensedMedium.ttf", + "size": 294644, + "sha256": "394a44da71a0c2af3386633e30b3a72bbea6decc28612b1f38344709ddb13144" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-CondensedSemiBold.ttf", + "size": 294660, + "sha256": "3df9d202455d64ffddd62d9eae9150926d7e42a9b09a39683d02349066fd9646" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-CondensedThin.ttf", + "size": 277756, + "sha256": "ec2baf876d172805a933adbd64c7862aaff00fec6e993ef99a75c1d3dcf7859c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-ExtraBold.ttf", + "size": 303288, + "sha256": "21ade4fe55be1ba2e997fc2df69da819a4ae5b84daa94a127d8f2f39a854fd8e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-ExtraCondensed.ttf", + "size": 279200, + "sha256": "015ebcf6d06ee7a8df5b5c911b9491f15d4b5ce4a7f583b1501ff09c980b6de5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-ExtraCondensedBlack.ttf", + "size": 303856, + "sha256": "8dc3a20a8818093f526a8463759c42b5702c814d15f930b82e6059d5b6b43a0c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-ExtraCondensedBold.ttf", + "size": 295180, + "sha256": "10670f4160c251364c7dcc7a95a96a59bcefab536498a4c800c97058aa59dfc2" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-ExtraCondensedExtraBold.ttf", + "size": 300520, + "sha256": "789b8abd3b73a92900e0221a8d0abf2f7747fd85ae353fac427b673740cb93c5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-ExtraCondensedExtraLight.ttf", + "size": 268580, + "sha256": "053cce281bd09e6273db74a16eba85419f32d3e3c22fa770f89ad5b3d3a59e4b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-ExtraCondensedLight.ttf", + "size": 271256, + "sha256": "a64d4564997f7be69a81becbeabb7ad604e589ea7e3916de201b54b4f47496ed" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-ExtraCondensedMedium.ttf", + "size": 282388, + "sha256": "1323ff3229d85b678c1301ed274e9ca39c25619c9248a4c589720262527eb9c4" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-ExtraCondensedSemiBold.ttf", + "size": 291840, + "sha256": "baf3a05823e4a0000da0f24c7487abd301d65357cf7eab4f564b51ecd2a80d47" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-ExtraCondensedThin.ttf", + "size": 277724, + "sha256": "9df8b160a589b8123c5a08df8158a8d88eccc7b03c84e1e39a6eb8bf2ed7bb34" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-ExtraLight.ttf", + "size": 269124, + "sha256": "5c6ca8f28cabc8c4b9a81bda93abe7e378617ff7fd741136f010bd47568acf54" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-Light.ttf", + "size": 273632, + "sha256": "6a59d8ff7fb5a28ffde24cce0c7b41049c56b643d1c50b9e761133f5a882573b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-Medium.ttf", + "size": 297436, + "sha256": "e69c009bb837ef656e5bc0e9c647e78153efd778e4a5101310a595839afb8aae" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-Regular.ttf", + "size": 280180, + "sha256": "e272d11d271c61a8d5d0f65764fd368ae53b9f9dabcce244a8941aa8f32ccc70" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-SemiBold.ttf", + "size": 298188, + "sha256": "4baf23ac58918838428c26773ea8cd65279bd230ec850a7e65306d147bdeec54" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-SemiCondensed.ttf", + "size": 281048, + "sha256": "21ea68ff2e8b25875998388c46447221a96658a08d656fa3c4c4a69a4c040751" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-SemiCondensedBlack.ttf", + "size": 301824, + "sha256": "b186d789fcba16058bac8cf5dd6c61a19e8d0c067f98e8089ea555245cc78701" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-SemiCondensedBold.ttf", + "size": 301380, + "sha256": "b779960ce93fac488d8211ea90b0fd20e8ad02b474e7dd8ef3623bca9fee0fc4" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-SemiCondensedExtraBold.ttf", + "size": 303468, + "sha256": "318d4907291bbed0b153009da2441cdadcaffe486e01e8acf15b8edeb6d833b1" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-SemiCondensedExtraLight.ttf", + "size": 266612, + "sha256": "7f0de495bbdb4227dba18b67ea94d6f1550d4c5829f072ac3c55d7efb8454a16" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-SemiCondensedLight.ttf", + "size": 273228, + "sha256": "a125cdbaaa1fcd20485db4e3c29f9698f25e33b2a47bed1f01b17116dc71203c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-SemiCondensedMedium.ttf", + "size": 295624, + "sha256": "3e815e50c8016724989033784b06624afb2542262f59bce441afb42ddb77a116" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-SemiCondensedSemiBold.ttf", + "size": 296604, + "sha256": "58855fe032d10568904a21f9c3db840857a47f3cb533c7fb656cc28fb241ff75" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-SemiCondensedThin.ttf", + "size": 278528, + "sha256": "452294490cb989bb9b5b29560354927c447014bd33cb6e746267722477dc6d73" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDevanagari-Thin.ttf", + "size": 279884, + "sha256": "aaccf9aca8970dbf0894ee7c0138986746d423da9c23d9839c6d2ae8de1a7017" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-Black.ttf", + "size": 732616, + "sha256": "936803317841def9d5326e271c019b872bc5be899f958bfdb69772fbb4b721e7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-BlackItalic.ttf", + "size": 700308, + "sha256": "81521db83ae0dc88cab3ceda474e0e6247fb40ee2d0bcb6fa2e22b71d48115e2" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-Bold.ttf", + "size": 682480, + "sha256": "bca7070ed1e6a8f5b2b08ad04dad4bed677c8044060ace0f78b2e01d425e841b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-BoldItalic.ttf", + "size": 682972, + "sha256": "b6521b09ecedf447a43f1eb202566ccf14d3ad7fb4f2008823762f71781405e8" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-Condensed.ttf", + "size": 683544, + "sha256": "923794c9842758be90f9a30ae1059e19c945696a445431995aba2d67a1c48ec5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-CondensedBlack.ttf", + "size": 690756, + "sha256": "b72d3849083c9623ceadd3806611163114413bec596285a33f0b9c8dbe18375f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-CondensedBlackItalic.ttf", + "size": 689704, + "sha256": "a7bf2cbae724e5039e711e558d13574fea535697aa70feddfe8237c3bec74274" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-CondensedBold.ttf", + "size": 689432, + "sha256": "ed9e306253817345e214114ca642037d75b30ab328a6c60b3f321fd5690c74a7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-CondensedBoldItalic.ttf", + "size": 687648, + "sha256": "00995d1e6ec62398edcd8b03a1e948061886eb0139d97308ecec9cc6062a603f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-CondensedExtraBold.ttf", + "size": 694560, + "sha256": "2d2d7b21e3f824a2c784f0e61c71aa0798923c038c11c30e4e3c24ac53f80764" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-CondensedExtraBoldItalic.ttf", + "size": 687916, + "sha256": "c25e96463f3c5a194db8f0b025a79a779891d814fad02bcaae6344faa59f88f7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-CondensedExtraLight.ttf", + "size": 707944, + "sha256": "c7f11c8bc44aad7888ff786992def9bc8d01362505a60b9d274e1418ddcb64f5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-CondensedExtraLightItalic.ttf", + "size": 688044, + "sha256": "85be0119ca5c4a5e469b10047d705a731070faf954b3e5705fd8fa086a3c9904" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-CondensedItalic.ttf", + "size": 684104, + "sha256": "ae83a0bc7fc5a58793a5be9ff4de67d0cf0bd3bc3a6d7d3d4a680c4bb90b3f43" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-CondensedLight.ttf", + "size": 699612, + "sha256": "6212a4738173def3aa02e7f30b3a06e9055aaff0e6e9ab1b5b28b25b2cba6a46" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-CondensedLightItalic.ttf", + "size": 688728, + "sha256": "60f07c06c00a086864fbf154a52f39fe5562558d0dfb72502dbab128dd23fe31" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-CondensedMedium.ttf", + "size": 683572, + "sha256": "91e28779bbe16c142f28be1923513f90265c14a1862a3bc8d259bf3dd3f0aa1f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-CondensedMediumItalic.ttf", + "size": 684920, + "sha256": "a00962b78aed04248e468556e782ef04680dd2fa415fabb8504038ffe2aeb836" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-CondensedSemiBold.ttf", + "size": 694192, + "sha256": "b65b0f9e3f43be1e4fb1f1f1f05ef55bdc12be39ec1281a59932528960c3a75f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-CondensedSemiBoldItalic.ttf", + "size": 686248, + "sha256": "f536cc8d5160a95e8481ea151372286b2b9ff3e324a8c988cf29726386ff53c2" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-CondensedThin.ttf", + "size": 702476, + "sha256": "33024a1a50c17e8cc53143538b502c96f7bc3aeee90549eab5a8fa363e62c7a8" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-CondensedThinItalic.ttf", + "size": 683500, + "sha256": "b3ddd62ca5ca4448aa0c821f7e945af1a097f35adc6fae3fac493fbc978c9f04" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraBold.ttf", + "size": 682412, + "sha256": "b4c3fcb312849a02f9899ce280d86c6e955ab409863bb7ab08ea2e02705f0bfc" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraBoldItalic.ttf", + "size": 686748, + "sha256": "20d08540b9ff0f12a61c22fe22a79d6140a641ba02639a32eafe541909e7b5f3" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraCondensed.ttf", + "size": 691248, + "sha256": "6c66c8f37311903ff09200838699b7956013d6076f3fba5e1e19c072156b3b3a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraCondensedBlack.ttf", + "size": 692624, + "sha256": "e4228fca9bbf5509ca5a8f620563c66c51c547f14612f7ac9f656728c05fa861" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraCondensedBlackItalic.ttf", + "size": 687652, + "sha256": "52694d0b0d30ae9b03e4dd3612d30c19e444ed78926da4fbc8605eede3e355fb" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraCondensedBold.ttf", + "size": 691004, + "sha256": "176e5c823dd1327ecdaf54f326d31d76df0a0b564539a7bbbae1a3f1c89b2e3e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraCondensedBoldItalic.ttf", + "size": 683728, + "sha256": "727f17ad2a8bdcabf1eb93dd878fd13d49ebaeec5d5d285bc3fccfd754af5a7d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraCondensedExtraBold.ttf", + "size": 690716, + "sha256": "7f5eef52656f42ed64046b416b0f903f27aff2e825cbe48f03fd659d03589133" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraCondensedExtraBoldItalic.ttf", + "size": 685380, + "sha256": "a6693e1e1ba0efdc1161d365e882ab89cf18b3552a31cc685938566067641de0" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraCondensedExtraLight.ttf", + "size": 698972, + "sha256": "fa4e901f296e620c063dcd47a4fa48f4a887a682845f35a98160c16d7b150cfa" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraCondensedExtraLightItalic.ttf", + "size": 685816, + "sha256": "55a6275345c25e615eb86a6e83738b660dffcd06caf2daec72ff960b26d834c8" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraCondensedItalic.ttf", + "size": 681380, + "sha256": "443045fd266e7a0250a30e281bfad1ce8b26720c031d4daeee2d0de91c68c184" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraCondensedLight.ttf", + "size": 690456, + "sha256": "707ea4ae0232c06e96eb12c8071ac25445d3b5718fb57f5ff73304bc77725f11" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraCondensedLightItalic.ttf", + "size": 681300, + "sha256": "18fa72de7e86890c07dfc04858f87974f2f43057dca35ec88f698e409eaf01d1" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraCondensedMedium.ttf", + "size": 686420, + "sha256": "5f6bbdc1ec6beb8e24da8a2218ec114d3aead542d7a25851bcf396320be6bed4" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraCondensedMediumItalic.ttf", + "size": 680636, + "sha256": "5768accbc8ce85cf3b8d3b0a1cb1b7317b3042b2f4b5da9a6a0e2097d46ed0cf" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraCondensedSemiBold.ttf", + "size": 688360, + "sha256": "3106fbd1a7d9d32a457aff4f497af9ee6372ea4884366f7fdb92bd034ca9fca7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraCondensedSemiBoldItalic.ttf", + "size": 681284, + "sha256": "c9c15bebd854f39d8980b2fde293f9d33aa2fa4b35b44abc0367ffea46b138cc" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraCondensedThin.ttf", + "size": 693060, + "sha256": "3444ad18518aa38c51c4d8209b60b2698a4918bf90323c05e8b0859cd64c135a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraCondensedThinItalic.ttf", + "size": 681944, + "sha256": "229ac27c39232b0571493b37d3afd3cd79ec146a5b4daf488ee724e65a06a882" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraLight.ttf", + "size": 700688, + "sha256": "c469566a7f737851de1dc0e40e49d0060bb8b1bf97db642aa63025487c2aaa9a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ExtraLightItalic.ttf", + "size": 684740, + "sha256": "03b1275602b3a813a064593a6d503aab2c17b3309071ea062b20b7d98633d85d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-Italic.ttf", + "size": 676588, + "sha256": "60c8e4d1b55d3346fd7113bb12ec9ff9cc684129c32aadfa37eec4d76223dc37" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-Light.ttf", + "size": 682184, + "sha256": "cc89e73dfb6dfa6307f83df492da10c6a84c3f9895087ba6d3c2e4c11d378fbd" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-LightItalic.ttf", + "size": 678832, + "sha256": "ea6dc9170fb788abc20d7226559e06e3cd0af6907ca212618b69c52375bceab4" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-Medium.ttf", + "size": 680660, + "sha256": "635e159e584cf9ec5b2d2fd8ac370003798682ef93a86513adbaa9c2522adc5d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-MediumItalic.ttf", + "size": 684720, + "sha256": "94a71a3dc3e54ea84fac008cc1cebe3024ecdad0259e20f7e98f874d053ea660" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-Regular.ttf", + "size": 670076, + "sha256": "8b45c367c7c6a4a1d82f132464b7f1aa8ff092df06602522b981daf1da64d927" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-SemiBold.ttf", + "size": 679648, + "sha256": "592fb09baf3c320a1a1c1b0da1de5fa679ef6014b23cf82f6b0a00bea3642feb" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-SemiBoldItalic.ttf", + "size": 685980, + "sha256": "0b2571e7b91066507eae0f231238a80e14650247af5879e4e3db6704a556cd12" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-SemiCondensed.ttf", + "size": 687896, + "sha256": "92feccaf464e68f609660363c2329f72c6410631a14e8adc94a01f44873e36e5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-SemiCondensedBlack.ttf", + "size": 733184, + "sha256": "0966d7ae8f483338e026286dbb42372e08d53f3f4fa7a861a81ed9514251762d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-SemiCondensedBlackItalic.ttf", + "size": 703160, + "sha256": "ea1d328afa9731c93b9463b1443d3496c91f46ada72dfa370acce8af72bb7c30" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-SemiCondensedBold.ttf", + "size": 680072, + "sha256": "5327398d1792bfa663350c8bcd0107979c30d2f621eb0ace7762dbf6d5bf1b3c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-SemiCondensedBoldItalic.ttf", + "size": 688364, + "sha256": "8fa293791fe9d8f853a7f843e2921f6b13bba8ba62b0b8d935dea990bbd864dd" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-SemiCondensedExtraBold.ttf", + "size": 690820, + "sha256": "f39277e6a6ac17c54dfdb0cb727ec4c2329c82f8ed57a914d10b8ee9278e2b01" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-SemiCondensedExtraBoldItalic.ttf", + "size": 690200, + "sha256": "0ecff70b26408a20b8eafca9511876d0ed43a3ed91b46b0bff7e82a1981c7b14" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-SemiCondensedExtraLight.ttf", + "size": 705516, + "sha256": "53e4b99c630be38499bb2eeaf2e9ca3a7e1dcd81cbacef30fd1e3553383204bc" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-SemiCondensedExtraLightItalic.ttf", + "size": 691056, + "sha256": "f0603f4875d8b19e923f9258ad2732f8be61cdbb22103192c3d5d899fe827ca6" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-SemiCondensedItalic.ttf", + "size": 687744, + "sha256": "485c6f63bf91617c2c4c67652b7c419c558b8d092e05517581d063e2362d8360" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-SemiCondensedLight.ttf", + "size": 691576, + "sha256": "fad04ad79c9a4891b8a586e0848b86a0270156f0471546e48bcf5cfe2d06c3e7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-SemiCondensedLightItalic.ttf", + "size": 687496, + "sha256": "4db4ad46c40d40b9054a2e9c3a0739fcc893b9ce5dd947d104b1cfaab40794f6" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-SemiCondensedMedium.ttf", + "size": 685812, + "sha256": "2dec50065a4e532606a88e1f61c6b12488a4fd436915657477c193f13e506e4f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-SemiCondensedMediumItalic.ttf", + "size": 685832, + "sha256": "e213a2a3f25abeada52e45a34928abc8a2422c6a62562c42f25b70a50b7cb251" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-SemiCondensedSemiBold.ttf", + "size": 687436, + "sha256": "ad8ce01a8183b4938d588ababa07cd405f2b6c2a89f751527fe58ea79c9ec27c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-SemiCondensedSemiBoldItalic.ttf", + "size": 686644, + "sha256": "aa552bd581b02b9fcdecccd6a8bdfc4d0789eb4bd167387fb70f84b329a16e9a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-SemiCondensedThin.ttf", + "size": 702708, + "sha256": "22b58caa06fd6fd66813688aac8875634b6742645f6ad6dc93de645f88fd7672" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-SemiCondensedThinItalic.ttf", + "size": 684092, + "sha256": "e3a6df9d0a274851cc8386b49d7a67dc05d679b764fd80f563d0d50e89956c3e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-Thin.ttf", + "size": 698700, + "sha256": "9b09c63a95bf234f22fb43e0eb1be8fcc7a0238c5c8202458d8ed30d60fc0404" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDisplay-ThinItalic.ttf", + "size": 686332, + "sha256": "287bc04c4a24d117ce68554aab19758cc15d96cd9fe2908785edba8c6d19f094" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDivesAkuru-Regular.ttf", + "size": 302744, + "sha256": "330cbe918a0cf52cc3e1eaa27114a077466d680af7819867b85cb27b64a2b07d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifDogra-Regular.ttf", + "size": 28800, + "sha256": "f2a5287a3b9d0a9a833c80b6c037fc998bc70544ee5f91f0245869013e171ae3" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-Black.ttf", + "size": 311480, + "sha256": "ce16591b9affbf5e2033feeddb7039a05427ac5dd86bb03f472d12c86bbf3e40" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-Bold.ttf", + "size": 308144, + "sha256": "79fee593757de9ba7f8bd1cf7234827c1797d30c8e5b482645897ae538ae6cf9" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-Condensed.ttf", + "size": 308936, + "sha256": "6ea52037ea2e55b4de6f881dfa791eba128dd494688aa469c1fdc08b2af8d6f5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-CondensedBlack.ttf", + "size": 315172, + "sha256": "ed0c19b9a238a86ec256529dd6c57c6cc86e3ce8bbaec1897c1d9c241a355d14" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-CondensedBold.ttf", + "size": 312036, + "sha256": "a8757c61e55d93af7f6703b9b20b5aa21e14f3f262c90bde2bac7ff4daac34fc" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-CondensedExtraBold.ttf", + "size": 313012, + "sha256": "b43f46ef6b7911b328d164a53d37911fbbbff8e87da0ab32e7335f94c06a7b52" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-CondensedExtraLight.ttf", + "size": 321260, + "sha256": "875fa6ee052a7a45599b496356d87f1cd78cba60f6b68c4379c73e4aadd02691" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-CondensedLight.ttf", + "size": 320820, + "sha256": "e9a753996d9dc4925f6fc22b4dad8f13dde180fb61c344cd78b3e9beb55c72b6" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-CondensedMedium.ttf", + "size": 313772, + "sha256": "cfbef1663556224551630ebc90b48e4984576bcbc6ee9bc75226abb850a3c0c1" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-CondensedSemiBold.ttf", + "size": 316424, + "sha256": "f8f653086bd354d8bfaf9e63f2ae2efccbc321d5b9971d4d570cfabe58834254" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-CondensedThin.ttf", + "size": 313872, + "sha256": "5e09f7f3e650d35916f04d05f01eaf9e9996e173d1a1bf9e79ac288409d95bde" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-ExtraBold.ttf", + "size": 312712, + "sha256": "93d6222473797a91c750477a987e012ebf64908b4c6fe408851ba80308efd73a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-ExtraCondensed.ttf", + "size": 307876, + "sha256": "07591ac81645b63cc06f17382ef4fb0c8b4761c064a46bfc584c3b2f232aefac" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-ExtraCondensedBlack.ttf", + "size": 307496, + "sha256": "64adeafaa50f5d3dc919d2cda32dc30d43c07b14d201fb4dfc3174563e51049f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-ExtraCondensedBold.ttf", + "size": 310376, + "sha256": "5548baf992842d7a8d08d05fa890aeec8919f660c7de3f31b452ddfe69a0cd2a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-ExtraCondensedExtraBold.ttf", + "size": 311008, + "sha256": "fe3f2b72ad29e5eb0c75d6bf4649c25aa7500a01b25e3ea000c6f42a9576c708" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-ExtraCondensedExtraLight.ttf", + "size": 321336, + "sha256": "786e350f1a77a6f8ed66f6379aa77fc2e898f064dedbb8de1d8d2e793b744808" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-ExtraCondensedLight.ttf", + "size": 319820, + "sha256": "3361ca022b820c01273cf7eb66b4423e5cc1d82d9beca6eb5ee674d218d9670e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-ExtraCondensedMedium.ttf", + "size": 311308, + "sha256": "111887a23eb91a4fef97b8e7b29faa268d38d9ee47b8dc90e010cf29ca1775fc" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-ExtraCondensedSemiBold.ttf", + "size": 318384, + "sha256": "2c0eae3a205186549eb778bb605482cdd3dd52f1cd1717c0ddbf759ffcd017f1" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-ExtraCondensedThin.ttf", + "size": 305012, + "sha256": "9c3365c1529952304c16c1f18279adadb35bf2a557132a716fe55e75fe7a8f28" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-ExtraLight.ttf", + "size": 319376, + "sha256": "35c22d9630a32a858370eb733fd6262da81292e594a11ba8220f4f95221228c2" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-Light.ttf", + "size": 318708, + "sha256": "dc47911636dedb2ae88e9916796a2acdd929686570aa9132a4331e0a7b2812a7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-Medium.ttf", + "size": 309428, + "sha256": "30358121c08e80d6f86ca35ee64966717ad7a26d55d3855b93f86b9600ac173f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-Regular.ttf", + "size": 309844, + "sha256": "4d14f10b7436e762d41ddd9486acdc708744bf377a575d855b81b847b9590779" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-SemiBold.ttf", + "size": 313080, + "sha256": "98175da3cdd69591f20fce1b140fc36b8b99908911b3f05bd1203180a84c7dea" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-SemiCondensed.ttf", + "size": 310124, + "sha256": "9ecefee364cc1f2553144458af3f07f3cd095d00217605150843d72956ab24b3" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-SemiCondensedBlack.ttf", + "size": 319296, + "sha256": "dcc12b4624f4aae2909320a19ef46dccc164be545587c0e313e7d11e49810984" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-SemiCondensedBold.ttf", + "size": 313320, + "sha256": "a3a658f1cbab2c83938314234a866befde27ca8dc0ae04903c36d5f9d4b8a68b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-SemiCondensedExtraBold.ttf", + "size": 317608, + "sha256": "307a6a273ebf93c201f6088089f81b04bcce52cf4e4072618f4b173027db2791" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-SemiCondensedExtraLight.ttf", + "size": 323084, + "sha256": "ed836bb31b1d0147a826252e49e262f180396a6cf88851b2669423d42e1038fe" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-SemiCondensedLight.ttf", + "size": 321428, + "sha256": "ecf8b288828e8a7953d9318025247964f28370bdaf598cb5fad1e7e72298e81c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-SemiCondensedMedium.ttf", + "size": 315752, + "sha256": "7aa71acd4a12349df3afff667414b833c410e62011ac6dd6e0437eb6e8860543" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-SemiCondensedSemiBold.ttf", + "size": 318656, + "sha256": "c6fb59414320591df19dc3929eed8d04e3eccbf6173d3954898225fa92d64ef7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-SemiCondensedThin.ttf", + "size": 313448, + "sha256": "a27bd45c938d6019b03e7948d9d2e787d4b734af355d6e0af5ed332bce12e869" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifEthiopic-Thin.ttf", + "size": 306760, + "sha256": "d0531d0623004f7d0fa844e5e6da2dfe95f7f7706eadd29d5d48b9559b0bfa86" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-Black.ttf", + "size": 65152, + "sha256": "4c8caa3784afb92ff967e88d9889e928dfcdf38c85eb26bd12638072cf78cd24" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-Bold.ttf", + "size": 64396, + "sha256": "6d30bd1fd83882002cab9da8e3fadb6bbe86fc710913a07305b3aea861de007a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-Condensed.ttf", + "size": 65336, + "sha256": "93a6a48da52520a7fcd48b63fbf4d61f48a6002b57d116ed82e3550fa16ebe7f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-CondensedBlack.ttf", + "size": 67360, + "sha256": "cb38cc5e7adc54998d6b753301416068dc5ac0650d2363f8ae1f2d3de8b2fcf2" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-CondensedBold.ttf", + "size": 67036, + "sha256": "27ddc8d19358956542d753eb321ccd23387ae451524ae9a294f2dbf6d4591122" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-CondensedExtraBold.ttf", + "size": 68804, + "sha256": "9ffb3a2ca1034e5ba8b7588766a3a5ae55612b2306da1c926e742a2fe62cfd33" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-CondensedExtraLight.ttf", + "size": 63544, + "sha256": "d3785334a7f26115708f885a844dc96fb4d1f8f38f21fdaf072b6c356ea043b1" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-CondensedLight.ttf", + "size": 63008, + "sha256": "2cb9cb5559926b882b452ecb4c47c9606af2609efd2699b7faed4753b209c418" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-CondensedMedium.ttf", + "size": 65512, + "sha256": "692ee71604c3a2e9079c7ab254b0f9ffa3d65a308d9e97950a9a2d4499f3c507" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-CondensedSemiBold.ttf", + "size": 65700, + "sha256": "012f230c5e6e042cfc32bf672081472e8702bc055bf906ae01f21b27cdd2e364" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-CondensedThin.ttf", + "size": 61952, + "sha256": "00bd212a1f7acc25f74845d0ef678c1934c3cb6614028f96825dbd88ac96cce4" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-ExtraBold.ttf", + "size": 65664, + "sha256": "16e4249aeed7ab6382c6330a3fc405ed45dcf34eab1bcd9ee15c9f8654bc1c03" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-ExtraCondensed.ttf", + "size": 63780, + "sha256": "8dc5997ba8431eed2719ccf935f1d16f7e17eff3bd1e8c48e706d15d1c56af9b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-ExtraCondensedBlack.ttf", + "size": 65720, + "sha256": "1513792c40593f041b2afe707b1f40b1fb9e4b7f2dfcd21ab29926de2231c0fe" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-ExtraCondensedBold.ttf", + "size": 64620, + "sha256": "e4f65a658a1947235a6a46cf2e81c57a638f4589b4da37869adb8ea8566b6973" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-ExtraCondensedExtraBold.ttf", + "size": 65476, + "sha256": "cd11997dd86680d552ac4496e4444dafcf686ae1c4ca7e5a24254cbdf0cbe9a9" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-ExtraCondensedExtraLight.ttf", + "size": 62144, + "sha256": "ebab013e6b701a50da8e2ddf903af482e2664fc38040bcf57f2b03002c8c1ce8" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-ExtraCondensedLight.ttf", + "size": 62348, + "sha256": "6f00fc27d91a83ba02ae8dce482aa4b8225bffa360481c97f4124a7b6e752207" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-ExtraCondensedMedium.ttf", + "size": 63744, + "sha256": "bfedb6d8541d9b59406f90bf8a5094576256c3ad4f05e8deb9699680a2e65463" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-ExtraCondensedSemiBold.ttf", + "size": 64140, + "sha256": "98af8a1654d33b0f19bfa19dded31487d45425ab66ad4f1c59cf75be45da534d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-ExtraCondensedThin.ttf", + "size": 61248, + "sha256": "81b1d0dc6bd60dbc501f2d1337840ed5cc514b374e792ada125f7e91ff1f8485" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-ExtraLight.ttf", + "size": 63028, + "sha256": "78bd57bb7678a8c17d41740c97388c23ef9f50ea63b2e82fa0e9f572185cb5cf" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-Light.ttf", + "size": 62016, + "sha256": "678509f40d369569e15b883c84d4c38543b5d0ca68f5916d785a171b249af270" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-Medium.ttf", + "size": 63296, + "sha256": "40f21f03b6b95b37f1403b2ccc43b3eb49adf3870b0b32eb7882e9547202dc6e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-Regular.ttf", + "size": 61956, + "sha256": "e222862f34ad05bdf950de1049abf6149d250b9e27c1cc482dd645412afb64ff" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-SemiBold.ttf", + "size": 63708, + "sha256": "e174109b53b4e1b491e4ed1abc60cc48bd97cd9ab4139cc9e036f6edbda96364" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-SemiCondensed.ttf", + "size": 63864, + "sha256": "403efa14ab7c535b04af3419e1f7b1c7fcdffbc09cb5cd6164d3701107313021" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-SemiCondensedBlack.ttf", + "size": 65864, + "sha256": "63f5a0c37703a98f4b941f318879992c1beff45fb89ded470e662109c3589542" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-SemiCondensedBold.ttf", + "size": 67176, + "sha256": "c23c1f85ecd793e9c5767b4acfb881630fb2b09fef689bbdd9d623084a83c86a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-SemiCondensedExtraBold.ttf", + "size": 68272, + "sha256": "2d113a56e9fec272d7778b8cd2b0e24dda23529e049bc61209cecb8ab9020871" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-SemiCondensedExtraLight.ttf", + "size": 63896, + "sha256": "c94519402f5678d5159f23180c77a15010415ccfa776238d71b80f3027dc2219" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-SemiCondensedLight.ttf", + "size": 62620, + "sha256": "4e90cbddacab76d54fdf19cbee80c26caf9189f013c389be3496f3587c8e9624" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-SemiCondensedMedium.ttf", + "size": 64560, + "sha256": "3cb7f4c717f91c0aa662f6f3016d2bf0a3a508fce4eb7d55585b564c95b9d9ba" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-SemiCondensedSemiBold.ttf", + "size": 64552, + "sha256": "1579eea6c6e835a8be1b2ff2ecbfb8e3b264a4e808a36a6654de5f1078e33c87" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-SemiCondensedThin.ttf", + "size": 63000, + "sha256": "94e9218f766580cbbc70f4da2707675877ccb9cd9c962168aa62b1c4b21790ac" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGeorgian-Thin.ttf", + "size": 62680, + "sha256": "39e2415dab6e98d2bcca002f7e384790308052fe5d7c79405c487e985b99f289" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGrantha-Regular.ttf", + "size": 365964, + "sha256": "3eb89dbf48e7b2b91893ac32e620509cf541e2445ea2c010f0bfe2e09e9e30fd" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGujarati-Black.ttf", + "size": 144804, + "sha256": "3331d9a451ad76ae196086977dddf7b5afddbd1ff23e2afbb51b6fc00f80aece" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGujarati-Bold.ttf", + "size": 148572, + "sha256": "85e32eec27cb59cb92e006f0d244241835cc341d51c87405c4796f4fe929c35c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGujarati-ExtraBold.ttf", + "size": 146156, + "sha256": "4a2f43f41ab370cb58afe6bbd836a9e00e4a24666a49908471e890f88ebfb4b3" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGujarati-ExtraLight.ttf", + "size": 141972, + "sha256": "2a735ea11ba86ac23f25df77b6baa3ffcef5ca2e2c0d76eb643525ad3922ac60" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGujarati-Light.ttf", + "size": 144936, + "sha256": "5131f7bac850a083e569419004ce18d34c9b480df1482aa18f6284f8d8b03c7b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGujarati-Medium.ttf", + "size": 146204, + "sha256": "8a9c61a2261fff026d400511ab9b87f9529ec57dc144d855fd259e49f150b54a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGujarati-Regular.ttf", + "size": 146056, + "sha256": "b8961ff6faf8e5561e3d0109953c4a9798e9bb8e8d48f66c3d1ef038fe45c940" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGujarati-SemiBold.ttf", + "size": 148016, + "sha256": "84fb6c79c1e03550d984d185ccd461be8c0517a2ba8f5dcbe50d23ffc8993f74" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGujarati-Thin.ttf", + "size": 140220, + "sha256": "844313ad3e3e9e9b0a5b74fa4ce0976d3f5919ff1920614d8d763229119ecc59" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGurmukhi-Black.ttf", + "size": 55108, + "sha256": "7eb35114c13436e8d68b18b267e258e770925cbe0e65617a8eff505c462a8194" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGurmukhi-Bold.ttf", + "size": 54256, + "sha256": "0a52407c35233c61305d64f5e7ca9e8113a1addc5141edc93dbd4837935f0e79" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGurmukhi-ExtraBold.ttf", + "size": 54812, + "sha256": "f093ff117bdc8de86027e7f98eb75d73b02a202e16234cf9f5e149b334f88cd1" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGurmukhi-ExtraLight.ttf", + "size": 52972, + "sha256": "2f946f9489c556e4872aee0e0f5983f7322398aa155b1bcd35759d43c55d1d3a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGurmukhi-Light.ttf", + "size": 53212, + "sha256": "fbe7d41e9d5adf4be4c31989f0d60b91e13f0a5946422556b7408dc2422b0049" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGurmukhi-Medium.ttf", + "size": 53872, + "sha256": "18a1f90b18ee9da8e323d0a791802056df4e365687019a89e1b696341f65867c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGurmukhi-Regular.ttf", + "size": 53316, + "sha256": "5ef2d04a6d6fd22959989d14148dcf0ffbb970cc35e6316ba63cd5c146f32667" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGurmukhi-SemiBold.ttf", + "size": 54600, + "sha256": "ab24ce12043b318657093b9dfd94a148b646b5212d8b416da1f69a0c3ca07acd" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifGurmukhi-Thin.ttf", + "size": 51532, + "sha256": "2510a2b57b42adf5c908aea03a46902af8ea8d22c8b2acb228a3a3c474bbb5ce" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-Black.ttf", + "size": 30244, + "sha256": "32abf74c221c220d5dfb41e7b5c5f600ac461ff4c4657ca8774f4b0572d937f7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-Bold.ttf", + "size": 31024, + "sha256": "9dc92fcff313103397ec593cfda58e68f88ee71ff2d4a32aa9cff5a8b2725ea1" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-Condensed.ttf", + "size": 29976, + "sha256": "fee8ff1ad9f965d85f937a5dcef75018e32579d9cc33134f6ac8b40ca819ee2b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-CondensedBlack.ttf", + "size": 31104, + "sha256": "cff69349fdb2891fed7229442a1ef54684510af1ad328747c2970936f9c6e8ae" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-CondensedBold.ttf", + "size": 30896, + "sha256": "ffa732351700ca033e972e3b0a79fb8f8772146bfefa90a86d4fb9ffc7e95b92" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-CondensedExtraBold.ttf", + "size": 30828, + "sha256": "a645583d4ce2da87ffeca4ca9b42894ee2fe2e4fdf9b03f2f675015bac7791b5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-CondensedExtraLight.ttf", + "size": 29492, + "sha256": "458a65dc705d0cd43abb31f3cb0501f4023379f467d2b21d6e03e86082550bb9" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-CondensedLight.ttf", + "size": 29300, + "sha256": "694430e5240176db51b7ecdef0baf8f875e037667f45de38b0d3cac1e5229179" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-CondensedMedium.ttf", + "size": 30356, + "sha256": "9d5d1024f1bacaf342540b9ba875571a79566f93c427b8c9fa1a0b72c6c9a63a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-CondensedSemiBold.ttf", + "size": 31032, + "sha256": "6310854b501567fe89579127a70f58d1cb45c24514d32be983d46fcbd8155f4c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-CondensedThin.ttf", + "size": 28980, + "sha256": "0819e3e72d92048768c5785144381608b0ed72d9670047b6a88e169b7203a9d8" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-ExtraBold.ttf", + "size": 31064, + "sha256": "b022ee484912781698bdf69f97de77b0170fbe79a6cd39590532c5e9d4902e2d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-ExtraCondensed.ttf", + "size": 30412, + "sha256": "e44f5704c110d126d1706944ab0c2b5bcb6bb6e16d659d50cbff5da7902f2695" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-ExtraCondensedBlack.ttf", + "size": 31004, + "sha256": "ac3784dba9acb2bfa010b6f6904cfa6496f52ceb8983d25f82ba4e148370c740" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-ExtraCondensedBold.ttf", + "size": 30248, + "sha256": "9989b720f2aeea417a5a1fb3a2054864920118c0ea23f839487e93881fbd5a20" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-ExtraCondensedExtraBold.ttf", + "size": 30824, + "sha256": "8ed9cf92b173c24cd2f205cbf6fd7573c975bc5f3fb5fa153fa9091a8c9fb60f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-ExtraCondensedExtraLight.ttf", + "size": 29444, + "sha256": "2698c0d97f5b37fd0998ddef3ae9c227ea82e4896052baa1aa490adb8adb9fe7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-ExtraCondensedLight.ttf", + "size": 29200, + "sha256": "2432ea687e01223093c0c33c6318599a81fb9f85f7fe7740ea095d56ee6c25ed" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-ExtraCondensedMedium.ttf", + "size": 30156, + "sha256": "b33d05af975a7b2e3d48be86def6effa3eb432f2951c8ef5e90b14014d981869" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-ExtraCondensedSemiBold.ttf", + "size": 30228, + "sha256": "6a503c8346ea470d3dda240e59d9ea022fe0991239ad2cab792cc69e9bc8eda2" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-ExtraCondensedThin.ttf", + "size": 29048, + "sha256": "ee997dada8f59ace26690c7f7a55f9954b1dc0f7dbd76f44c53060d1775a5503" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-ExtraLight.ttf", + "size": 29284, + "sha256": "654a695305cc2c9525a8d080182412f460eb116c32ae72045832d4d3db6f6de4" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-Light.ttf", + "size": 29384, + "sha256": "b6f87c3103b45cb82f6e6bdecc7dfbb8a2de8974333e7d9f681201f4d085d5c7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-Medium.ttf", + "size": 30908, + "sha256": "c5d4dfd5b95f788ba710478f15b006028033e1ca4bd55e01d926af7c828c4ce5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-Regular.ttf", + "size": 30288, + "sha256": "dfd5a6aefe97a99f68fe43388342913d50bb9fbf6d3afc4d2c7725661bc4a2b1" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-SemiBold.ttf", + "size": 31200, + "sha256": "5c42e5392c9a804964e93b22325f897a9c88e70ee3a317bdf465d4c4d888bea2" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-SemiCondensed.ttf", + "size": 31156, + "sha256": "8b75785567cbd3c3921efc2aa7388708d423e60c76fb1e5087fe533a3ea46265" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-SemiCondensedBlack.ttf", + "size": 31276, + "sha256": "01e197167860fb908868a2465ae66763f6bde464f52b3944b7ab1b4d3d372daa" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-SemiCondensedBold.ttf", + "size": 31124, + "sha256": "350fc9bc5f1015997cb6f352f023a7d364f505f06c1c2115215638ba800dfddd" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-SemiCondensedExtraBold.ttf", + "size": 31036, + "sha256": "72738faa0abf970895d0af638db9c098b94e325e692c830ffae2c2436f16bdd5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-SemiCondensedExtraLight.ttf", + "size": 29544, + "sha256": "7b83879e1c01d41afac8f7049157bee858c09ed6bb1444a9e1ce2cefd5e38ca4" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-SemiCondensedLight.ttf", + "size": 29560, + "sha256": "a799870bfa2c6145b0d96836b3111a328679ccb829f13bf27b4ef74e77c9448e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-SemiCondensedMedium.ttf", + "size": 30400, + "sha256": "4a446dcc69d68e94013180408b2c089203a0d1e97bdb5bd60eec8698c2e60d26" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-SemiCondensedSemiBold.ttf", + "size": 30852, + "sha256": "14610c5d2700c065605a79e3424a59735884da72013341ea215520f91ffd0067" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-SemiCondensedThin.ttf", + "size": 29108, + "sha256": "84ec352feabef2eaafe952fda6353debb2aa9014f4037d49ea9cfa00b37652ab" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHebrew-Thin.ttf", + "size": 29020, + "sha256": "b50e66db0a188e1c3fc0e98647ab5317058e55abc430a8f95b1c094ca81dcde7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHentaigana-Black.ttf", + "size": 149568, + "sha256": "4976f61cab0b0059206906a1719e1de6239ac6a1427a5bd1e848b943cd6c375a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHentaigana-Bold.ttf", + "size": 149032, + "sha256": "70c5f0cff15cb8f1358b8e72c396a3428dc676ec2ef2b363db0f19a34426f957" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHentaigana-ExtraLight.ttf", + "size": 151992, + "sha256": "a9e1bf64a74ee84ea7a16016d88406f275a19aae7a858545d801f32a62f3c515" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHentaigana-Light.ttf", + "size": 151000, + "sha256": "61583c97f8e04f09bc90a3427ec11645be0aedfb7f985fef9b5aa5af38444be2" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHentaigana-Medium.ttf", + "size": 150028, + "sha256": "f761ea337f0a93b122c568749445bcef6ecfa126650eb256465c76263a8e8817" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHentaigana-Regular.ttf", + "size": 150320, + "sha256": "2231394c949fa1cea871d943cf7ff7c0552308fca9067d70b88a533351d6bb5c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifHentaigana-SemiBold.ttf", + "size": 149592, + "sha256": "4d776cf26f14ac49ddaabb417dbabf142f574c2aca1519de624179af902a0c15" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKannada-Black.ttf", + "size": 189696, + "sha256": "ee843825ce17df297e89db947c299c38bef1840080838b715c8cdda82edaf0d7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKannada-Bold.ttf", + "size": 180648, + "sha256": "1286a1fd0bad103672bbd9783c227cf806603dd744502b706f7a01f33345e424" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKannada-ExtraBold.ttf", + "size": 188048, + "sha256": "5dbcfd44c908405be44ad272ac6883a2f18a284d5034b4159138c4df3d23325a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKannada-ExtraLight.ttf", + "size": 151500, + "sha256": "d96131361a9f46eec2a752a41165d707b7d9407366e81cd5258d598747236b31" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKannada-Light.ttf", + "size": 164904, + "sha256": "747dc8ff1fe185e0d04d40daa007f2c9ec51d2ef185b1e8af147993d281ff9a3" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKannada-Medium.ttf", + "size": 160580, + "sha256": "eee0b89898e5b1d0595116cd0a6534865c358ef1657e0965630847cd604c498b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKannada-Regular.ttf", + "size": 194376, + "sha256": "95a78c95359dc59b9ec56bdc5bdf8a9642394d2ef87ded945fb3885893b3eac6" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKannada-SemiBold.ttf", + "size": 169396, + "sha256": "f3b0295e9b54cdd9bf97dbd6ede91828e172ba04f2ac3bf7f73ed9932b6aa111" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKannada-Thin.ttf", + "size": 151164, + "sha256": "1d85b4149328b99244212c5cd30d8073fc3ab9149f04433367d975e6bbd73392" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhitanSmallScript-Regular.ttf", + "size": 609232, + "sha256": "ad6d20d17e7b0af746106b8e0e3ac65c47f6813a4acb6e05786023e1374a953f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-Black.ttf", + "size": 152288, + "sha256": "5eca8ef6a04abe41feb30736a7023975cd36864bb97f9b207bb3fea2fc008ec2" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-Bold.ttf", + "size": 152152, + "sha256": "946ed867d7a058a6b5010c235c1a857521a5bb6a7eb28cff722fb32d7ac108b0" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-Condensed.ttf", + "size": 149688, + "sha256": "7ec683209390b6ef35279cba32c0b2b81b4430e6937f76d5a2908c94643f22ad" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-CondensedBlack.ttf", + "size": 149792, + "sha256": "18be0bcc1bce4c1251b53fb96ab87f54b5ee3970d1cff750832b7a15875f8752" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-CondensedBold.ttf", + "size": 148528, + "sha256": "35da872476faf345223d1b29648f3620537b74959c1735c0bea130d4e7178a2c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-CondensedExtraBold.ttf", + "size": 148060, + "sha256": "ee68784ea0855c17cafb950c6bd3096acb79c57c842d9980c7b8cb5c0b369fd5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-CondensedExtraLight.ttf", + "size": 145912, + "sha256": "705e3f6b57a4800bfdc50d5e1cd5acd9ce291b63d5b8c228e7c16d595a2d54ed" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-CondensedLight.ttf", + "size": 145908, + "sha256": "cde1eb9cde93aac06cb817f82a4922bb75c0b264731a9fe10abf266ae5061c6e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-CondensedMedium.ttf", + "size": 146156, + "sha256": "a3f195b5d5bce743be51458918dee533a897f3c8235b22c8ea5b171f9ba5bdd0" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-CondensedSemiBold.ttf", + "size": 146752, + "sha256": "2b9a7fb870f65ffb6175b80d56ca82d661f30aa58f13e68be4da007214a366fc" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-CondensedThin.ttf", + "size": 141100, + "sha256": "93798fd55a365d1fcfd5a41c7b8ae46dc408fe2beb98e274d6037be1b39bdd1a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-ExtraBold.ttf", + "size": 150388, + "sha256": "a0aae1647ededca5497a90c2342e131755638e4b2ee6015d39ac28c85db248bd" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-ExtraCondensed.ttf", + "size": 150444, + "sha256": "86a2d5a4124ed80ba6621dd5ef8f877636dcbb6099c108838eee39db12dadb6a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-ExtraCondensedBlack.ttf", + "size": 148200, + "sha256": "9fefd2b2851616258c4a7176432a507cb85cc59e71dc2687ae98988768e6f98e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-ExtraCondensedBold.ttf", + "size": 147948, + "sha256": "ad27241061a1a0cc49be41c8b311ad5fd8bdbf0d6dcacdf4fd3060b7494f7925" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-ExtraCondensedExtraBold.ttf", + "size": 147900, + "sha256": "73608d33efda978726e44f71a7d786df30dba4b244c2da08da7878f01f791df7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-ExtraCondensedExtraLight.ttf", + "size": 143120, + "sha256": "975513176291771718a2139127ba8c65e804e68747966d9e9740a9913ff71003" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-ExtraCondensedLight.ttf", + "size": 146720, + "sha256": "ab2203533cc4e58032db609a274e018aed2039e9aa024b5e1b1a2aac13a344bc" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-ExtraCondensedMedium.ttf", + "size": 145200, + "sha256": "64d4374f606d4dbfa942b793a5b611a946e819e27e1dd1036aadb28d813c7b68" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-ExtraCondensedSemiBold.ttf", + "size": 146616, + "sha256": "f47cef8c061d028fadf60bd650b0c98352aaceafb3b6171b6bc1987398eb1c91" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-ExtraCondensedThin.ttf", + "size": 141864, + "sha256": "d03f87f3cc7f6a522add463f8ebeaeb898e1c0c1f2d401e870227db7796f6510" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-ExtraLight.ttf", + "size": 143948, + "sha256": "eb31c9c36add1fd44160452f1fb75a9ff175c6640de7bc6c07a22a3aaea51377" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-Light.ttf", + "size": 150796, + "sha256": "f29941f9cc5d1172d69daffd0be361f83b06361637e0dd894b02c1ebe59908c3" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-Medium.ttf", + "size": 150704, + "sha256": "3da5a0f17bf284c2b692d3cf8ad14c2942d55a75eb3180c20f6331754dbbd7e2" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-Regular.ttf", + "size": 154372, + "sha256": "ef0612212c7417da732dd43c744abf66f4f62c45d4f8962e42c8553230e0da2a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-SemiBold.ttf", + "size": 153572, + "sha256": "a444b55f0fa32d77535a6d2dd93a8e8701052535681924ce65123dd69f8927ca" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-SemiCondensed.ttf", + "size": 152044, + "sha256": "ef03f41629872185ae68557a5a05c25058075facdbc4e73c02a8d4141c89b909" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-SemiCondensedBlack.ttf", + "size": 151960, + "sha256": "3014278aee83eba7c03b94a9fa787d6601605189dec16fbd2f2ef154b5ae7dda" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-SemiCondensedBold.ttf", + "size": 150544, + "sha256": "0477905b21faa2af35fea63d4618f34889dd234ee9adaee3c90de49e6724ce6e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-SemiCondensedExtraBold.ttf", + "size": 149108, + "sha256": "19a6c2cd57b23e5679eaa8d2c3481bec0dc770705d3b7889b21cc8c336aecbb6" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-SemiCondensedExtraLight.ttf", + "size": 143844, + "sha256": "42e75f1804c8ed0bf30f6067c28501e80d511d8deed03d660468e99b25c70a2c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-SemiCondensedLight.ttf", + "size": 146380, + "sha256": "324112060c890a4acc56a2676b6d06376010bac440123ecdac2528fc3ff73f1f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-SemiCondensedMedium.ttf", + "size": 150792, + "sha256": "37f9d55647a53a3a9fd176d35b7f1fcffa037e8f56e50f7c3cd6813cf788d07f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-SemiCondensedSemiBold.ttf", + "size": 148536, + "sha256": "b5a45b259a763c56c8f652be65de3b99a8cf2a392a628c233587f7a8a333ada9" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-SemiCondensedThin.ttf", + "size": 142548, + "sha256": "a72f0799cffd0cd1a07d4d1269bbdc9152c7c50d20a2831f119153b7b3741e0b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhmer-Thin.ttf", + "size": 143328, + "sha256": "02859cc68813be5aed5b526804f443dcbb6a730d7faedd6ccf6d44b15cd74efa" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhojki-Bold.ttf", + "size": 147916, + "sha256": "b447ba8a2661f4f32b2138d502535ee130d5336d5588ce63e2eee8c9c8ed81d2" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifKhojki-Regular.ttf", + "size": 146988, + "sha256": "ab7738bcbff4be789ca459877eab7f31add89d1515decba4097abcaafcd9d604" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-Black.ttf", + "size": 46388, + "sha256": "0d2faa6003e948d3b9a49a82364fc0c2d69c9c662f59eaca03b733928c644a5c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-Bold.ttf", + "size": 45336, + "sha256": "dd05fa383da220014853d7bf10f2eae5dd2e5eb564d2bcbd1aa9a36efdd059b1" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-Condensed.ttf", + "size": 42768, + "sha256": "fd4e66c5463f4064e57997859a678fe418ec5f780d496f0202f5a3908ddd0c97" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-CondensedBlack.ttf", + "size": 45488, + "sha256": "e56c2bbbb022a7eda30daea5ffd3f68a8d74c8ae46bedd2d93928c75a8d54a1c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-CondensedBold.ttf", + "size": 44636, + "sha256": "6baa8046a0ffc50de3e1c3c1b87a5882caee1d6c61cb19ca0d80fc064285e471" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-CondensedExtraBold.ttf", + "size": 45344, + "sha256": "57ccfaac3d67f6c3e8d08f9a23d3a30db93c0ab13cb194c722382ea158b031c8" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-CondensedExtraLight.ttf", + "size": 42728, + "sha256": "c0486da0b89b21263e2684ef5497f160fc06862d57be862edc716e8c0dd8e453" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-CondensedLight.ttf", + "size": 43328, + "sha256": "e2b9bfb3af173837a8e9147aa1c49282c849f66b865d78569f4572de61d42d4c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-CondensedMedium.ttf", + "size": 42976, + "sha256": "444d3335dce289ca5bb8df4999c6684fc2f66f85239d05bfcd78bfbf51fc7cc6" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-CondensedSemiBold.ttf", + "size": 44760, + "sha256": "3b832c75bb10ec71a7de05f0e58c1d14d06dad954dee2acd1a58d41b81cc1d04" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-CondensedThin.ttf", + "size": 42820, + "sha256": "1eec949cdb179262497533b17326aed04e50386b0e88b3235a23fa02fceccd1b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-ExtraBold.ttf", + "size": 45512, + "sha256": "bf61b451290278de86f48ddbcb5c7419d473a77cf0749e06a00f67302aedc97f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-ExtraCondensed.ttf", + "size": 42476, + "sha256": "9e38ba95adb946ffd5ad8447c2b99be3268263d62013d86f90ee4d9a8028db0f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-ExtraCondensedBlack.ttf", + "size": 45312, + "sha256": "20fd5906680ce86a0c9d3e24d19552da510c73c1ec607dcbc71cfb3b3bfbc221" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-ExtraCondensedBold.ttf", + "size": 44244, + "sha256": "4778bbf7166dbbdda9b605b1401464696d727a42fdc05cc311dfd3e122e4c2a1" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-ExtraCondensedExtraBold.ttf", + "size": 44944, + "sha256": "f1b54a43af1d0ec7052f465ddddc7c7d211c7eee38913f039cd4f2130ee4ff49" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-ExtraCondensedExtraLight.ttf", + "size": 42592, + "sha256": "03505b21b8b600045998ae613088643853c90614a7d74fcae7c5c352cdc10dd3" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-ExtraCondensedLight.ttf", + "size": 42868, + "sha256": "8371e86fcbb12194228b17e67049fa144623e5742aff38c2c0fdf81562d02176" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-ExtraCondensedMedium.ttf", + "size": 42912, + "sha256": "b5db9b7006fb36e20815ead5ceeaad3d1c49806c0797661b78e44f0387895049" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-ExtraCondensedSemiBold.ttf", + "size": 44496, + "sha256": "ce0b00f4314ef12bd10fe38bea2d516b6c6d05a751bcbe14c9d89240aeca1564" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-ExtraCondensedThin.ttf", + "size": 42772, + "sha256": "ceeb2ad7d403024639ff922d9a23111d4c188826ad546c6102ece590ff803267" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-ExtraLight.ttf", + "size": 42904, + "sha256": "668b3213a9031d8a0e5df32d7e101304fd6a537c2a785611abd2d2ebf368d1b0" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-Light.ttf", + "size": 42888, + "sha256": "8bf6c3c04f42203e50679e8a57a3c25a74a1856c5ad75aa9b09e0385aaf25a8a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-Medium.ttf", + "size": 43328, + "sha256": "bb488a60830a5e657101ea3524c388e238bddb9de81aa2c712633429fb33ef05" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-Regular.ttf", + "size": 42880, + "sha256": "a8d38866520f3c96d0e0e43f079f16c72ae4963bbabdf21cae26092c7cb65ec7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-SemiBold.ttf", + "size": 44600, + "sha256": "a6329788ae6606841ef09a72a2e64552ede4b371b2c55ae2e6feaf3a0415cbd6" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-SemiCondensed.ttf", + "size": 43032, + "sha256": "9a6e61505d065e798de47423a2cb4eb3861a97b48793524b3b8c6f7ee2ce73b0" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-SemiCondensedBlack.ttf", + "size": 45988, + "sha256": "e0afe68bb9fb5d3298a38f2dac4c00cbfb1d0af16cdc0e117d5fec5e54647188" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-SemiCondensedBold.ttf", + "size": 45236, + "sha256": "a9274b555d5c31648b236c8c64344b2e15cb71a57a4367ec4c2c7186beffae01" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-SemiCondensedExtraBold.ttf", + "size": 45304, + "sha256": "2435dbae1dac2b3791b3d4004f6a26cbbc300b60b85447a8caab7f898f4cf8ec" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-SemiCondensedExtraLight.ttf", + "size": 42916, + "sha256": "cf3f86a6da2d58264d442011978977f414e2513a2666c3951eba50210aef49fe" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-SemiCondensedLight.ttf", + "size": 42596, + "sha256": "3d9715ddb046e788c498f31d0349b6467ac673d548ad0b782d166ee6fbba9915" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-SemiCondensedMedium.ttf", + "size": 43236, + "sha256": "dfd40daa9d1befb0b296a3b569c6422d89696271537be32affe38efee8b11494" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-SemiCondensedSemiBold.ttf", + "size": 44660, + "sha256": "b0a17d3f7c7acd861510f2f0e2abaa1b5d9e68f5efe84c9e4252088889c7eaa0" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-SemiCondensedThin.ttf", + "size": 42956, + "sha256": "08441a70ebfab19f46f8831d2f8d1989d218f40d1f6b4b58d59bf6b79a694206" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifLao-Thin.ttf", + "size": 42448, + "sha256": "d73d159c3c4c2c9b4087247194a09f8e3ded8eda9c53b2617f4b71ad982dd1ef" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMakasar-Regular.ttf", + "size": 6992, + "sha256": "eadde425bf710f2df6c3ad195e143372a178f05839bf742c3997beb7e85ad1c8" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMalayalam-Black.ttf", + "size": 116252, + "sha256": "b4d0ccf956b088cc00803fa658fb16c6ca34327c38666685fa54cbe979858002" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMalayalam-Bold.ttf", + "size": 113036, + "sha256": "a1f0d204ccb42fb22ee9f81d3b316b5b058891864bf9f3f5b2e16b2adf54f249" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMalayalam-ExtraBold.ttf", + "size": 114148, + "sha256": "ea4f42816952d4893b9bdd5d8b5556e9c95c6a910f95ddf7eeacbc09cad71865" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMalayalam-ExtraLight.ttf", + "size": 116228, + "sha256": "c64d8c35f9130599fb4f76adafe22cd6e924864c4c15e761eac15c2ecc459cff" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMalayalam-Light.ttf", + "size": 109120, + "sha256": "6c58cf5322640a6791f915d24ad71740c5c0544ad6e65c0561ea4ce0a43d6172" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMalayalam-Medium.ttf", + "size": 112316, + "sha256": "53151901880d431deb88882cc0f1f7e0099e0ad535471ad919dc05259e874e90" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMalayalam-Regular.ttf", + "size": 111872, + "sha256": "45c34ac8c169a37a4fbb5b8fbf507def328bbf36180b08095726d460d577ec1d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMalayalam-SemiBold.ttf", + "size": 113240, + "sha256": "cf5baf39cf5f5063901c646aeee77c1497d61df3412c9e1082b7a86438afe679" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMalayalam-Thin.ttf", + "size": 106864, + "sha256": "35140ec9dbd289d1397190ff474c0d483d25fb415f06ac2ced9c955c95889e27" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-Black.ttf", + "size": 289676, + "sha256": "83753dee49ea3a651ea4fd1a3f7b617fe862c41166e9eb9cb3a7d2b835315113" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-Bold.ttf", + "size": 283636, + "sha256": "80cd19b7adea5f46a893d617d9b4d0481e4ca39edb6cd6ec5c688f25353da977" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-Condensed.ttf", + "size": 271644, + "sha256": "79b35eb2c59758a527aa700631b3ae4a38bb34cc5a2a9b3fe1b5d1cc8b2fc528" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-CondensedBlack.ttf", + "size": 280528, + "sha256": "3ecf968ec153e81c2aa8bca0ec6c334c186136494964e65e3ca41da6cdf6f35c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-CondensedBold.ttf", + "size": 277348, + "sha256": "2d2233bff35368da659caedd3c1282f573ed216ba70745a8a97751e49418822c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-CondensedExtraBold.ttf", + "size": 279628, + "sha256": "292ccc04d0355df05cfe9b7e3618ca29e52cf994f28073ac7fa4b8941fd25150" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-CondensedExtraLight.ttf", + "size": 271932, + "sha256": "71807d1001ff6312fb337fc23c1b6b6218e652aa0b12a0242beab9a9dead58c6" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-CondensedLight.ttf", + "size": 267644, + "sha256": "15c3649d4429fa146a08d7a527471aed30ae78344daa1f931d714e048fb39ef3" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-CondensedMedium.ttf", + "size": 271596, + "sha256": "5fcb82f3a3c573dade6f98f2dd07ae4caec6adbb9c6840db9490df55afb5c04e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-CondensedSemiBold.ttf", + "size": 271828, + "sha256": "8950daa19c9aa4184649ff25f96d2c153bdfdbe9ce776e656c1c75c34c25e74b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-CondensedThin.ttf", + "size": 258184, + "sha256": "e7271d6f7aa956e8b12b850739d028565460915a7e61975f5a7ae8c35ab883a2" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-ExtraBold.ttf", + "size": 286272, + "sha256": "0d54199d346640a1a7d9a349eaf014d324f27026a8bcb438bf8fa7253d40b6e3" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-ExtraCondensed.ttf", + "size": 270436, + "sha256": "adc82df0f94d580b851fd71e7e983a07aa74f58680016ab93985e737c562c141" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-ExtraCondensedBlack.ttf", + "size": 279480, + "sha256": "97244475a680096854711b999f6cb4639247735ec7d71f1d0401a559e487b014" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-ExtraCondensedBold.ttf", + "size": 276368, + "sha256": "4fb68d39e05d018789730de6a7a6728c3c403be27a687d54f1ac6bea96ce0607" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-ExtraCondensedExtraBold.ttf", + "size": 278112, + "sha256": "ba012275724d7c688ef8b575daff33cd0f25ce6822f809ff6519df4fb19a9be5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-ExtraCondensedExtraLight.ttf", + "size": 271536, + "sha256": "a035d86d06877caaa31f312ee1d8a981b1fabf758b6a8a7791f7eaaa5373b6f6" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-ExtraCondensedLight.ttf", + "size": 267688, + "sha256": "8dafd39a72ac5d29759bc4a9d8e7ac4d9ff4ada635cb49c58fe84b60385d4301" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-ExtraCondensedMedium.ttf", + "size": 269752, + "sha256": "6144499715d772be68134d46cea25fed1c1ce093cdce2455f3f2f88c53c8f54d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-ExtraCondensedSemiBold.ttf", + "size": 270792, + "sha256": "498e2a2f6f976182538321f00dab349bc703bbe792482cb5fac7c96d4602cc45" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-ExtraCondensedThin.ttf", + "size": 256416, + "sha256": "c227aa02b6516fc6496ae98fdb18088d9b0aab053cbbee4030bba88ce0b549bb" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-ExtraLight.ttf", + "size": 267492, + "sha256": "899587bb4de8202e8478297d0875ae09142bac2ded459867fa183ae2ea0d161e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-Light.ttf", + "size": 272280, + "sha256": "da5eaaf424097be9447d057dcef03c88f0c9db28f958d5e5db813393ede84e73" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-Medium.ttf", + "size": 278400, + "sha256": "024f0d7cdf9cd977a02d22a9b9fdddb66b7135bd12162f7c9d81a25074ec0879" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-Regular.ttf", + "size": 277152, + "sha256": "fe1a5f1c9cafab515e89e20f884aee3681ccd9f3f2e7bf357c6758d9268535a5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-SemiBold.ttf", + "size": 280848, + "sha256": "82d7acc426cd71e08fc49681fd64ecfb92d218df54dba4b9f28f9295620ed72c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-SemiCondensed.ttf", + "size": 274116, + "sha256": "4ad94ad5a31598d15077a54889cd7c1be6efb646546b71f58a7982555f0b9d0d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-SemiCondensedBlack.ttf", + "size": 284800, + "sha256": "8866dcfa8e590b01ae3a900f606842a1e88cc0f21cc13fc7e833d1307a627552" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-SemiCondensedBold.ttf", + "size": 278940, + "sha256": "2c9f67799ee57b6296d2feeef0b20ad427a8f14987cc69641736230d39ec523e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-SemiCondensedExtraBold.ttf", + "size": 281968, + "sha256": "de90ab9040e5c3cc77079e7fc182dfa32287073c8e3e3170a9a8191509fe1b1e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-SemiCondensedExtraLight.ttf", + "size": 266288, + "sha256": "88b90bc68f86d3015f9389652626524877e4b19472ad02dbbd5a60cd24e70d02" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-SemiCondensedLight.ttf", + "size": 269288, + "sha256": "a4a50d1ece8abe7c1ccfe68e92fffd58273b9cebce2efb11262035e8fe8c8329" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-SemiCondensedMedium.ttf", + "size": 272616, + "sha256": "53abef9c89dce5da18550a2dfb78ce05446d0cfd203a8f1958f5feab149b487b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-SemiCondensedSemiBold.ttf", + "size": 274824, + "sha256": "88487aa76eb8c48fea4ee540428757b6ffd868c58d59de5c603b492c612cf62e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-SemiCondensedThin.ttf", + "size": 260516, + "sha256": "aea5a481c91e7a12bba8a285b880edd313bc9c9c4e9e6df5594d76ef46cbdcdc" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifMyanmar-Thin.ttf", + "size": 262364, + "sha256": "e147fe5f9bc511c63c8c09dbdaa3b5e70c7d043ec5f9d63f676cc3b18aa5b904" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifNPHmong-Bold.ttf", + "size": 24876, + "sha256": "f35c2fbbd1d32f2965c6c6431848dd658cde2ba13ca99bf6025e3e4a46d065f6" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifNPHmong-Medium.ttf", + "size": 24436, + "sha256": "83a64116d2ce0fa5f192baade0bd26fb2917dc1846171591aa8d6b150259c607" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifNPHmong-Regular.ttf", + "size": 24376, + "sha256": "becff5fa38bed12954dc62a8461e80231fa44307f4ae4c96b31a3c0df0e04bfe" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifNPHmong-SemiBold.ttf", + "size": 24692, + "sha256": "2b578cb72f33d71d54ed403cbd86add452608f4bca926231b22fc5053a6aaecf" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifOldUyghur-Regular.ttf", + "size": 31624, + "sha256": "c38515e1174b069b5ddf4d4b1a813b44a6bb697bc380a174931024984efece31" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifOriya-Bold.ttf", + "size": 147812, + "sha256": "a590010d4a5033daf5ec0e9a6c83eea879d9778767966bee8235ae10cdd468ef" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifOriya-Medium.ttf", + "size": 150872, + "sha256": "b8eb05f2d05ecb17d73f551639db4b1b4f8fd3f114f5ae8fcf16733846d25b73" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifOriya-Regular.ttf", + "size": 148896, + "sha256": "c674ab8038d1e51e783fdd7192fd39ae8a2aa48701589955bdabd627c0c3eb13" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifOriya-SemiBold.ttf", + "size": 151388, + "sha256": "325a11e02e21b48bf26e6907330451c4a123fde5248ead4f7bf6580b979a5a54" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifOttomanSiyaq-Regular.ttf", + "size": 19268, + "sha256": "e5fdc7649582757942504e81b5bf29d03c9c5327bd61b2732297cb6a3afae856" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-Black.ttf", + "size": 343268, + "sha256": "2b27691abe354761d2cabbe46c063db471fe1dd0131d2d7177437b70703f0d63" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-Bold.ttf", + "size": 337684, + "sha256": "f556f505f7189c013797750e17ac97645af07672e6971bdd8452f0eec202e259" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-Condensed.ttf", + "size": 314472, + "sha256": "7aea9c0612f4140ecf5a8a6e9bf9d2113958c581a3b2cd474ebba7bc09d30551" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-CondensedBlack.ttf", + "size": 341236, + "sha256": "b61ce76346880300c46c7870c2f2a56965de5c1926d8494571a83de3d60f776e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-CondensedBold.ttf", + "size": 348856, + "sha256": "26448753b388d5e95b7f679981efa80fd11bfc53ef778e79d35787c43113cc1d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-CondensedExtraBold.ttf", + "size": 341144, + "sha256": "4308f91165ccc510c1d917e2f22f7c466637b629e9f808790caae9685ee8ba54" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-CondensedExtraLight.ttf", + "size": 314148, + "sha256": "854c3406407302b55106a2f58353a8352af7b50eadf7c3978b50ba98154dcce7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-CondensedLight.ttf", + "size": 326628, + "sha256": "f6a53e5e861658ad604cfa8ef8fc66602d323351872b5427dec5d8ae53e3c3d3" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-CondensedMedium.ttf", + "size": 318564, + "sha256": "8b00c48a0709a7fdf8c08ef21e4de28c6fe1331103104367ea01e9ebba7d975c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-CondensedSemiBold.ttf", + "size": 320076, + "sha256": "71302892c186b41623eda9a52e495b39afe110f15ef90ef91b7223d0fbe900e9" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-CondensedThin.ttf", + "size": 323108, + "sha256": "1caf91fc4412919e87a46b33b2d7718f1d1e2fcb35c0269efe05939a478dcf95" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-ExtraBold.ttf", + "size": 347780, + "sha256": "af5d2db05c5e05b4a98a74181da846e746b7e87d49e176f8b2a29d1fe8fab034" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-ExtraCondensed.ttf", + "size": 315344, + "sha256": "5b64d222ca603b76d1b4121a32950046d63b4531c28e09aaf1bbb618ea1f4853" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-ExtraCondensedBlack.ttf", + "size": 336308, + "sha256": "53f971748ac89483588bede3f1c17af03d0b228836afdd72efee88ea03823ef7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-ExtraCondensedBold.ttf", + "size": 333128, + "sha256": "c012f8b8d46be018ca71029205dd4f6d9099cb00be4cf07180fb5ebc0cd7ef23" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-ExtraCondensedExtraBold.ttf", + "size": 328876, + "sha256": "fc2316146a8a67c09b3e45c16d30aaf54a38f7b20ddf6e923dad37eadac0a375" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-ExtraCondensedExtraLight.ttf", + "size": 310760, + "sha256": "c306d1a40d46dfb771fe8a778e14f7c9fcd70105478ba1f0b3bf02f60b2a321c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-ExtraCondensedLight.ttf", + "size": 327876, + "sha256": "79e812ca2d8b0438eba53665254b2793c5a74b5cf2704599b2a9ad626fc6ac93" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-ExtraCondensedMedium.ttf", + "size": 319312, + "sha256": "d0d8569371de0691d0bbc59f54c5df458cf19ce89fb9650970346bf5fc29872f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-ExtraCondensedSemiBold.ttf", + "size": 310152, + "sha256": "9a273585a85454d716204100000267a73e9234b14bdc3b8b8e0fd336eb462c98" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-ExtraCondensedThin.ttf", + "size": 318256, + "sha256": "3b6330c49cb528bcc444066fd032bf56c9d0386f3a8a2be0d5d8847073a9b2bc" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-ExtraLight.ttf", + "size": 327512, + "sha256": "c2f8615592af2a3c96aaf11d489fca8c83a323063370f06c62f6f7f7f9fe4cbd" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-Light.ttf", + "size": 338692, + "sha256": "92493b1ce897ac8200ed69313d97e54c6a736561ab8f27231a4a4417e091eff0" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-Medium.ttf", + "size": 325508, + "sha256": "eb5fdfb34c1d8023bc11d465e6cf61f906682045e45cad2bea32141f05461a8d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-Regular.ttf", + "size": 325860, + "sha256": "0d5a63d34ed378e345ec96021d72385c8e288ecd0ea36511a329aed448ede375" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-SemiBold.ttf", + "size": 334900, + "sha256": "eb7bb3ca9c771cbfb3e81f69440887aafc8fb7c29e7e8191774a45541711c353" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-SemiCondensed.ttf", + "size": 331788, + "sha256": "628d316180e427c6725f1953e9bf992ded0cc9f777df8ba3a6ea1dfa64c54fc0" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-SemiCondensedBlack.ttf", + "size": 342240, + "sha256": "67ed55cd9726e69ee07f50346a752102f26b515df0c65f11e09b4e2b534d0ed4" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-SemiCondensedBold.ttf", + "size": 344456, + "sha256": "3d6c62c307ee45c84f2b5ecd057f17f810f888b714ebeeec304af79f81d904bf" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-SemiCondensedExtraBold.ttf", + "size": 348428, + "sha256": "08636d40ed4fbdc046d939824a8087e428fb2317a09d49c9988d1778f205a7a8" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-SemiCondensedExtraLight.ttf", + "size": 323388, + "sha256": "babfe516416d79e069a855a0ac865b811bfe1ae1a1ef8517862faa63fc3d84d5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-SemiCondensedLight.ttf", + "size": 341008, + "sha256": "16cd9b6aa1642e96eb4e55db8747ba93e29bcc481b90497cfa01b41cb44be4bf" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-SemiCondensedMedium.ttf", + "size": 317072, + "sha256": "43d1be57edad83e1d128acfab2965e7eda1d04896abd6ad05451a1ee24b4cb8a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-SemiCondensedSemiBold.ttf", + "size": 327692, + "sha256": "c0fe78fa65379cbd4d856cc652e1bf29f07da5dc60f27a0a6effcfc815a3f462" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-SemiCondensedThin.ttf", + "size": 337152, + "sha256": "9c86e9619c2ca0c187fb40fafd389c07596adc6f056492a82f2f03ee418c5702" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifSinhala-Thin.ttf", + "size": 323040, + "sha256": "b31e143f56873a9c63f6fae085f7f72d8acc6934df70ed1b18d1205301f49f8e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-Black.ttf", + "size": 82996, + "sha256": "b25f26d195f26bc1f5485a7609312abd2f870d6af085021f57cd20dda1bec545" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-BlackItalic.ttf", + "size": 83264, + "sha256": "d13e056b255eb3f997bfd4fcbabc3baf71487b38695e1098c63915740d3d7fac" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-Bold.ttf", + "size": 78376, + "sha256": "0b3f994281fa5777e5a6fdefa51f0a98cf9b7ea3c25ccf7d448af5f1958fe0eb" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-BoldItalic.ttf", + "size": 79740, + "sha256": "85cda857a9fc90b8b779b94ce619598018b871e9c79f54f75064008c70a82eff" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-Condensed.ttf", + "size": 77440, + "sha256": "3c8a4673776a8cca6ebeda2e4c830fd7b058a16ab41650dd3cc07aed529eb068" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-CondensedBlack.ttf", + "size": 79636, + "sha256": "b03168af003bd6f18df7b7ee7d6fe962ef9c70d115fea7c802cfb9e4266f7f71" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-CondensedBlackItalic.ttf", + "size": 80216, + "sha256": "d2f6f712576d9b80d43b0d2e8accc9a8bd6f28c577e9666a1bb8400ee1ac41f0" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-CondensedBold.ttf", + "size": 78756, + "sha256": "ae167fd64d438cf0fab2f111ec85c5809fa84743ef85f6ae8ef68954cae33732" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-CondensedBoldItalic.ttf", + "size": 79352, + "sha256": "e261ad8026e6330e7b320860bd9c0862b67c64a0ef0ec307dd5593995b56606d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-CondensedExtraBold.ttf", + "size": 80800, + "sha256": "463b272842fec8cfd8b910a4341035206a7893531dd4522388b2ea4856aa1541" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-CondensedExtraBoldItalic.ttf", + "size": 82056, + "sha256": "02e1a2fec5de3748b6918e992a5ab022100cf1147f2afae56bed1c4591248fb4" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-CondensedExtraLight.ttf", + "size": 79704, + "sha256": "7e3cf9ec88c55bf17ca1bb19eec2cd90b57ef1d5640196dfcb4e64476bd091eb" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-CondensedExtraLightItalic.ttf", + "size": 80364, + "sha256": "c23d677dd36d780f5683052bf4ddbeefcc5f6d4fcaabd4c0827453798b272087" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-CondensedItalic.ttf", + "size": 78296, + "sha256": "9cfd68aa964db301dfe97ceb65e3707c928156b7e2b14ab9b40664705c58e42d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-CondensedLight.ttf", + "size": 77552, + "sha256": "9a99cc6ffcf3e27a5d1e7739c2c0e1ad4b9830eaec03f6c877976db7bbdc1c29" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-CondensedLightItalic.ttf", + "size": 78300, + "sha256": "2699a3af7283f2f7cd4c38b5a70c60a284447101d7f54b4ece909e09b251874a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-CondensedMedium.ttf", + "size": 79412, + "sha256": "3c9a99598f5ff4c6692106c6b43c474469fb80c83137808f039774d84c337aa9" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-CondensedMediumItalic.ttf", + "size": 80352, + "sha256": "bec1fd9437858bd70d07f79390fc4c7fa543a0c33a138cb83e02a0528b94b760" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-CondensedSemiBold.ttf", + "size": 79420, + "sha256": "265576477a833389e761364e0509b7f38c718d9bf6e823ec93cac8cc0397c630" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-CondensedSemiBoldItalic.ttf", + "size": 79696, + "sha256": "b382ed24ad4af092e974e87c4ffa7ce3379e8928c8ab83b558e7c80efb2e251b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-CondensedThin.ttf", + "size": 79200, + "sha256": "db8d65795452850157e720aca09bde9f5128ddd763d5aa41f7c9f609c73a0c20" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-CondensedThinItalic.ttf", + "size": 79992, + "sha256": "065a7d27daae1d7c1d2d919d68ab3ce013d7a1e6e679086eba74e9b5ba51ccff" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraBold.ttf", + "size": 81404, + "sha256": "12631b4ac97189ee268dd5473130a44ca86aa09a884a490a585581004f2b85ae" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraBoldItalic.ttf", + "size": 80880, + "sha256": "62f65038e3d599b6c64ce6003a74aeb1fb6876eb579a826a9c5a265ede0485bc" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraCondensed.ttf", + "size": 75976, + "sha256": "704b3896b9616c003554edf66dbf494abadb6f7a5bda987c2f389b2af15552b6" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraCondensedBlack.ttf", + "size": 78572, + "sha256": "5d4ba1c22275bce71ffa7f704b20c8df244ae473b55bb37eb216898e60f60ec6" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraCondensedBlackItalic.ttf", + "size": 80276, + "sha256": "4b6173d68b4db2954f4c3b84cfe059a3d2a1651f11deb5fa65babf72b823081f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraCondensedBold.ttf", + "size": 77964, + "sha256": "fa6c21d3133f91afb97ca7e3e903d87c4257a130ca25521d50ae259c76dbc942" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraCondensedBoldItalic.ttf", + "size": 79828, + "sha256": "4eafca4c84fa957c0fc00fbbd3b673ac581f5d03ef7ea80c390c03c8a490fdff" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraCondensedExtraBold.ttf", + "size": 77908, + "sha256": "a05f90f9a77ea33577e15ad2ababfac0c79174c7c18f615e9f83ab772d3dd8ec" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraCondensedExtraBoldItalic.ttf", + "size": 79124, + "sha256": "5053cab6c3ad6bd225c0c8df1563e68cca5466becf619da704a03f69ceab8c26" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraCondensedExtraLight.ttf", + "size": 78532, + "sha256": "32a9b56c49281f62fb5f6cabbe8f1b305cb18bd49e9ad15727dea77410d1285f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraCondensedExtraLightItalic.ttf", + "size": 79316, + "sha256": "78b5e5b1e5ba5998eb7cf794f5c01fc808752d4fa4bf8852601ce5297fe6401e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraCondensedItalic.ttf", + "size": 77168, + "sha256": "cbb3e026e5852dd97ce7f3a03981fc294e729f233f52dd40d54093a1be9e3d0d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraCondensedLight.ttf", + "size": 76968, + "sha256": "d862db4fa11ac5148583060772ac173fd075df3da4f9f61c06eb065f32525f6f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraCondensedLightItalic.ttf", + "size": 78216, + "sha256": "ec1040e9fa1998c17164d97b1d9fd916c9778c1c4b0236a47de044d801cb0985" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraCondensedMedium.ttf", + "size": 80000, + "sha256": "0d4fbaeaac8ec3825a562d7162e106c64dacaecfbf6706654212a244ed719bcd" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraCondensedMediumItalic.ttf", + "size": 80508, + "sha256": "373d32307659f44964ddb6fe1de306569ee4be5de90d6b96f804d63bbcdba2c9" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraCondensedSemiBold.ttf", + "size": 77820, + "sha256": "8e61d227398c442d715531cb8b672f294915a54b85463827f0a40c32d6d6fe7e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraCondensedSemiBoldItalic.ttf", + "size": 79204, + "sha256": "3c1c8129170a8c78fe959d96eea678173ae5ba28762b9003dc93142ff5fa2163" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraCondensedThin.ttf", + "size": 78324, + "sha256": "3ea15432a7ad3f8a51380d639e488b995e0b070273aaca8f996cbc3cb834c43d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraCondensedThinItalic.ttf", + "size": 79696, + "sha256": "85d78745f53332d985d2b484c2387b81857bd7968b93e12a0c0763d91430c116" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraLight.ttf", + "size": 80172, + "sha256": "6e0bd3715e766f5bafab65601ed0e344ca10d595a01dfb644ac54a656f793717" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ExtraLightItalic.ttf", + "size": 81420, + "sha256": "2d1bd2cbf3b11bbbabb3aa3101a46f0e16e574ba622a38edc76e265985100880" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-Italic.ttf", + "size": 78816, + "sha256": "c2601356d6fe1f7f22cbbb53bc20384a4707598a519ccba16b6d747c758837ca" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-Light.ttf", + "size": 78424, + "sha256": "6ef40035001c0b427514786465c3876222f1a700b72eb489b0a1ec0ee45bba5d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-LightItalic.ttf", + "size": 78916, + "sha256": "afe71c3352b018bfc5cbbc4265656578433615b870fc7a5e9f47172a7a166f5b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-Medium.ttf", + "size": 80436, + "sha256": "4c839c5899656bb47e9cebcc8ff3b5580677ff28be67586a43720599b50b8dac" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-MediumItalic.ttf", + "size": 81732, + "sha256": "80670ca1cb368d9692f7e4bc26e6d739096672af5f7b842bc736a3f913a30c15" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-Regular.ttf", + "size": 79476, + "sha256": "8a8cb015a3afec2998e01aabcb42fb365994132d95dbc2150208b4fefd8dc2e4" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-SemiBold.ttf", + "size": 77984, + "sha256": "1691423a69a365792151ba4a044cf891237d3f38655be98b4490df12fd5cee43" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-SemiBoldItalic.ttf", + "size": 78716, + "sha256": "d604813810e57106831455732433946e81ceaceabe2545a13bb8adee29f98d4a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-SemiCondensed.ttf", + "size": 79944, + "sha256": "9ea373c957aa0519cc5824853e0ce1e7ec96a35145d5f33b8599a988bda710f0" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-SemiCondensedBlack.ttf", + "size": 82704, + "sha256": "a2106e9947567583b88836dd8c48771efec8bfe2fdacdc220e71cc061718abd5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-SemiCondensedBlackItalic.ttf", + "size": 81284, + "sha256": "94a783dd07c64c5c81c6470bd696b2e63f0187601802bb1e6c0986f33a690316" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-SemiCondensedBold.ttf", + "size": 79564, + "sha256": "230fbd068ef5e59791a1895a43bc2d212a4e56c325db421b346d452334604536" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-SemiCondensedBoldItalic.ttf", + "size": 80728, + "sha256": "f9236fb007b507ada9adb8e3120f5f9da80e3bf6965348c72f83a55af039f579" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-SemiCondensedExtraBold.ttf", + "size": 80404, + "sha256": "611fecfcafe4c0b97585a812d32a796de9e6194ce14911e0a91a2aabadf0b9d3" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-SemiCondensedExtraBoldItalic.ttf", + "size": 81252, + "sha256": "1af40db979b6fc1317f5732272c1c12c4e2fa3c5dcc6e18ca4530b9234ea5c89" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-SemiCondensedExtraLight.ttf", + "size": 80392, + "sha256": "d30952db7e352cd8b57e59df2397ab16a03d82d1dccb2357c7c7272e29e13aab" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-SemiCondensedExtraLightItalic.ttf", + "size": 81216, + "sha256": "9f02a0d69861df4d1b9b0acc6df0986163da4a8c2eadf27da19d0702ae4f30c7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-SemiCondensedItalic.ttf", + "size": 78504, + "sha256": "bc1d025434032148397e12262fa89b2af2b8b7616fde799113d6dba9f5e09524" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-SemiCondensedLight.ttf", + "size": 78744, + "sha256": "30dc46039e4e3b6e2be28abba71775857a0b46dff4bf6a3578448079b090ee94" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-SemiCondensedLightItalic.ttf", + "size": 79352, + "sha256": "49e3ce2c4eb6094bbfdaffafce9168e7c508e76dfd997db4aa4ac5965951eafd" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-SemiCondensedMedium.ttf", + "size": 80580, + "sha256": "a8306fa36b1c27faf735d81c943e3cc1d8b10f6b28f6b60ce11e715341b1bce1" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-SemiCondensedMediumItalic.ttf", + "size": 81248, + "sha256": "d9f1ca0230a34a06fcadc3beb4d4977a88baeeadc78e2fe73b32248e2fb90bbe" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-SemiCondensedSemiBold.ttf", + "size": 79336, + "sha256": "9c10228e1bb639b8f05fa23e08e4ce728dc56f438f699466756235402e66ce5d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-SemiCondensedSemiBoldItalic.ttf", + "size": 80892, + "sha256": "02035735d9beed67b638224a802a6ed0207a30996d306b4d94ebd98ce1f4c74e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-SemiCondensedThin.ttf", + "size": 80408, + "sha256": "449e956558312b9b5564fb7ce00f792eda122b1acc34a2f7c239995e8f7a41b7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-SemiCondensedThinItalic.ttf", + "size": 81052, + "sha256": "a33f5d82b21f9236f1439c6732ff1b5b07be0900adb8d04563261ca57187fc73" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-Thin.ttf", + "size": 80680, + "sha256": "fc66edfebb9358a98d01fdb23321e8b3ca840462caaa565d75c38e2b0d7310f3" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTamil-ThinItalic.ttf", + "size": 81268, + "sha256": "4158c644cc070c8c503e2e9f419b1c047405772ec7286464138a8fc049d580a6" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTangut-Regular.ttf", + "size": 3065476, + "sha256": "edcc5b7727fa4d1345d2fca93305c8b90ad88d4975152d4aed79564c9117db91" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTelugu-Black.ttf", + "size": 369992, + "sha256": "382442c138bc0d3032ee613cd6e8fbc6b03014e8e415b16baee267de696a28df" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTelugu-Bold.ttf", + "size": 331876, + "sha256": "c3e590deff913f4e38803238c4508bed2e65dcb6759fe6c484362e839b26d7b1" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTelugu-ExtraBold.ttf", + "size": 377072, + "sha256": "060df8b5e347ed9c1bc913382e1b1f277b95c810f2e55ccf849b96cd887e570a" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTelugu-ExtraLight.ttf", + "size": 319944, + "sha256": "f4bd28a1c2b2fc402faa1cd18e06c985d786de2f96a007c1929dc8ee6b904a6f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTelugu-Light.ttf", + "size": 288484, + "sha256": "f00064db747eda7ae43379fb914a81662a7ead4d7e15aa6c77733a12cf360ce8" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTelugu-Medium.ttf", + "size": 304292, + "sha256": "7c2fd20bac2bcec3d6028525d6eab5f030adac666ff897744f1826d9009b7612" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTelugu-Regular.ttf", + "size": 311724, + "sha256": "2b376e0351fb140f2edad9c54e8977f4f1effc4fe72443e152b3f6ad392c6f5f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTelugu-SemiBold.ttf", + "size": 310408, + "sha256": "fb5905551e8b5c75d035c779662a3defdb7cd8d3f9f6db191d394e5a57db8b69" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTelugu-Thin.ttf", + "size": 290952, + "sha256": "069a38ec2a441355082db2a17ddf13dd270189726a11d3bae4c25a90ea7f3b02" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTest-Bold.ttf", + "size": 2796, + "sha256": "fa16e990fc2ee253440184cf979a42d489967271b286fa91f0cfa12a2da11830" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTest-Regular.ttf", + "size": 2772, + "sha256": "81d2a61eeefbb35cdbd2ef9a57fdcccc1cb33d651ec440d27e3ce5f7e7c23160" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-Black.ttf", + "size": 48424, + "sha256": "495c31538daca2f6664907005af4ebbc46c49805b37d8e184562e630ba349c60" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-Bold.ttf", + "size": 46544, + "sha256": "af3f64944248fe0dd9a37c0d2e8eed69930623d962eb5b2130fa81bb9e879dd1" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-Condensed.ttf", + "size": 45152, + "sha256": "4661aa6289363b49546b35484646bacef18377201437445b8d0396c81f4f9c42" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-CondensedBlack.ttf", + "size": 48360, + "sha256": "6de1d85fd0f250ce7bcf9337d5955840ada35d4f52dee5d20f5667a5ef640a85" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-CondensedBold.ttf", + "size": 45536, + "sha256": "2cb6d481cd073552a9f4ecdecf4d849d33f520d746aca6aac2a94bff0cc0ecba" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-CondensedExtraBold.ttf", + "size": 47740, + "sha256": "590becea26f9d3dcf1a36a911200034c84abececd43a2d47dd80c07788e5d46e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-CondensedExtraLight.ttf", + "size": 46288, + "sha256": "27e5ec2d4e987ff3d8cf61bbf85323498f696cc881c91692c2e19269334321a5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-CondensedLight.ttf", + "size": 46204, + "sha256": "23de21f2b4c1a703013a0403232fd2066f98034620abf939fe511d728cf59362" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-CondensedMedium.ttf", + "size": 45340, + "sha256": "abb3a26dfcb8fa100777d1baa5af4dd4c44356cc10ae1f807796f7c7833e96b0" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-CondensedSemiBold.ttf", + "size": 45920, + "sha256": "74e8e048e7513c76dfcecaa9ada226113e0d53407e3a9f90ae9caff5972fcb61" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-CondensedThin.ttf", + "size": 46528, + "sha256": "02381a138e894ddc078cc6fa6a14f6607d639fccab0266915f82a3bc1d3082f7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-ExtraBold.ttf", + "size": 47444, + "sha256": "a337a1a1f8f4c87b991a80faea33dd16e68e54bcb1df680cf369b87bfecafa2c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-ExtraCondensed.ttf", + "size": 45280, + "sha256": "adafe94b1dc85d5749d39854e9b03ff4a61c47ddc05c9a440ab0e69fd8874fbe" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-ExtraCondensedBlack.ttf", + "size": 48284, + "sha256": "6a7211cc7eca2150cf0d8a87d4aa8a2077b65ef8091ace4317160981f9c0b5c1" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-ExtraCondensedBold.ttf", + "size": 45176, + "sha256": "234459843d73d23199ce3019e45fc5ce168f6e6ff6448a22b4e887ae85339ca2" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-ExtraCondensedExtraBold.ttf", + "size": 47916, + "sha256": "c0330393cfe5c103070183a5f224eda75a6a9afb91750d671bb5e7c6c8dceda7" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-ExtraCondensedExtraLight.ttf", + "size": 46040, + "sha256": "bc3c8f88f880f6143fffdc58c1d6966d6f21dbeb5db50aeb8941653a960c942b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-ExtraCondensedLight.ttf", + "size": 45952, + "sha256": "d2195519d0ceb1c696bfecf95542a010c111f7c470491a16a073ef6dbbfbc5c2" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-ExtraCondensedMedium.ttf", + "size": 45060, + "sha256": "13a23f7f4ffcb658c78a46ce9d16814269e01561addc77f7e75b46437aeca4d4" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-ExtraCondensedSemiBold.ttf", + "size": 46164, + "sha256": "47b55a4a495cd761d5ae333ebbe1f1f60bbcdaf3249aed105e149846d3207877" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-ExtraCondensedThin.ttf", + "size": 47220, + "sha256": "8a04cead8dd9b9ef194660fac3be0de523343fef4fc81fc1f95e9aa28fc1eb83" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-ExtraLight.ttf", + "size": 46940, + "sha256": "0a3fe3787e19efe4ededb1d743ce3d8cc8e03d19f06ce9841149433e496c0ae0" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-Light.ttf", + "size": 46448, + "sha256": "b03cb8b3c90c64e26dbfe94c68c1d302fa607fb779d60805e5d31d97d9db5f97" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-Medium.ttf", + "size": 45488, + "sha256": "59bb1cbea784e5b5df38ad60ee193eae030f25cd22b461bf5e9f72decb4d047d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-Regular.ttf", + "size": 45424, + "sha256": "747b5bb1fdd329850ddd3218d872a3d2a96ab0dd544f51e5300c6d1b12071b62" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-SemiBold.ttf", + "size": 46816, + "sha256": "662fd2666066477649bca721953bebe9d530add47aae4bee96a3ed8cf0ac21a8" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-SemiCondensed.ttf", + "size": 45420, + "sha256": "a143952303e7adb3ce7c17cc947db02e2fd93d45c773f446b031c253c38c4887" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-SemiCondensedBlack.ttf", + "size": 48252, + "sha256": "b9d94f8b8a83abe5c01513d66ae94aa7f872efd91898fa919dce376165dec914" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-SemiCondensedBold.ttf", + "size": 46376, + "sha256": "b7deebdb35afef6be56ec6afee529e7d5f58b040daac0b24067bc01f09ecc5a8" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-SemiCondensedExtraBold.ttf", + "size": 48116, + "sha256": "b0d4682946c5576c19e2354989439377a45d900a014f907129c8152e8888cd9b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-SemiCondensedExtraLight.ttf", + "size": 46776, + "sha256": "af922ee8fd7d26f8ae830bf821b82c0a2db96fe73e21220e2d58c9d9d79320c5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-SemiCondensedLight.ttf", + "size": 46036, + "sha256": "3e063b9c65a08266762c82e2ea413880684e367009ad5b05ca94016bb34d67e5" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-SemiCondensedMedium.ttf", + "size": 45440, + "sha256": "b5998718d148515f73934fd219542952f5c93d4164565309ada611607f0a3466" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-SemiCondensedSemiBold.ttf", + "size": 46376, + "sha256": "69621252e2a1ec8ab32a31819c9dde5ba1eea9f7b2242e9767216c116a685148" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-SemiCondensedThin.ttf", + "size": 45876, + "sha256": "ce5b6957d841317daf2391125dc6487f547bb80c409e1fcbef913494165fca16" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifThai-Thin.ttf", + "size": 45168, + "sha256": "cf5406bc04c05a691d008fa6be01aa0afedf4a98e2259acf76f3a170a640506c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTibetan-Black.ttf", + "size": 590864, + "sha256": "a0cdda95ebc1a02766e0f6cea996033998f6e64fb797bcd9197d8f23f7b9c28b" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTibetan-Bold.ttf", + "size": 602368, + "sha256": "1e8ca12dff8bf6adee21f5b0a6ba827fc2f9b3c515655983438371d1275e1d53" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTibetan-ExtraBold.ttf", + "size": 597868, + "sha256": "ce11859d6b8814c1cd559ad5e76f509694c53f076b2f0a2d83c8e8247ade0e35" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTibetan-ExtraLight.ttf", + "size": 617080, + "sha256": "df4fad61f620d564b128483ef6bbe8ece5e69978d8a2e9c3b4f123e92917b8e9" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTibetan-Light.ttf", + "size": 613040, + "sha256": "d334dd7823b53b41f9c14678971772ebce334b5f92c5bd7024454f75b3b47b17" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTibetan-Medium.ttf", + "size": 608512, + "sha256": "e144a22ce21c839820ce359a14d71997014bc1494a4ff388a728d8f053309324" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTibetan-Regular.ttf", + "size": 609688, + "sha256": "ee97bf3dc56e813651db734c9f35f8f1d41e7e31acf5f7d893e64ad22b292446" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTibetan-SemiBold.ttf", + "size": 605976, + "sha256": "4a3b38d0435b6611ad7ed4b60e417eb8f73d19e23440046279a1af3df15d4845" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTibetan-Thin.ttf", + "size": 623556, + "sha256": "57037369d5f05652c7a7ac87d2ebfe280a44f6c5116a846ff54b50b30a3cc9e9" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifTodhri-Regular.ttf", + "size": 11104, + "sha256": "4ded6e98752b13363cf01dad267efdd9481d556b9f2d5291ddcbda7fc7342727" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifToto-Bold.ttf", + "size": 6932, + "sha256": "77a752914101c4e801902c4c60309c280a0c417f593f694b4e11b400175277bb" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifToto-Medium.ttf", + "size": 7032, + "sha256": "ebf6fded321bf7ef7a28240d6372d623cd3430c916fb573a10f38785570902d4" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifToto-Regular.ttf", + "size": 7000, + "sha256": "882a1aebfc4f1824a413628859bdabb1e3a49ba90c2e023b0dd6bbe550c7332c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifToto-SemiBold.ttf", + "size": 7040, + "sha256": "9a4ddc5b3fd6d94df4f0fd3d4474ec0c5359cb3d19ee60c28543ad129aef22e3" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifVithkuqi-Bold.ttf", + "size": 19056, + "sha256": "7eb352098fe7fb735acd4a7d31990bbaf79b02b5a5977d9d2adf6791dad2fa5e" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifVithkuqi-Medium.ttf", + "size": 19052, + "sha256": "3236a261081bf3f317e934b176e6ffc2942b5671e1e402e90d3f022da1e1a2ad" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifVithkuqi-Regular.ttf", + "size": 19092, + "sha256": "f6fbd610abc3a42de1063f6077ff965195d3b148541a9acf3fdfe7c8d368958f" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifVithkuqi-SemiBold.ttf", + "size": 19004, + "sha256": "d3d3633dc3a19f51e2f76417063e8702dc37dbd8523478070d138190760d7580" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifYezidi-Bold.ttf", + "size": 18072, + "sha256": "3c0f3a6bcb8bfa7a61d964817b55c49e26a8f373220b016debd3ba6e45bfbc41" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifYezidi-Medium.ttf", + "size": 18276, + "sha256": "8b139b57fea14710a655cb9ff7d8f49ffa3eafda6aa83c2b1c7f58ae816c147c" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifYezidi-Regular.ttf", + "size": 18252, + "sha256": "fb30c28d72e2dd2468a848a2cb282b2781c9ae9b84a9e2e070673edcc4438f7d" + }, + { + "path": "/usr/share/fonts/noto/NotoSerifYezidi-SemiBold.ttf", + "size": 18256, + "sha256": "8b370dd8a9e943fab4b1ded5787800aad8fedfa97ae6c4521a5fd1fd2f7cea2f" + }, + { + "path": "/usr/share/fonts/noto/NotoTraditionalNushu-Bold.ttf", + "size": 104624, + "sha256": "b9d4b96e8d37f74c2b44e2645c63ec18e4221018cd52db86dda3e93ba6a7ab14" + }, + { + "path": "/usr/share/fonts/noto/NotoTraditionalNushu-Light.ttf", + "size": 106828, + "sha256": "5e63650db398d680489524f0b806139b3714fa7acde3b291c5e30ced37a70198" + }, + { + "path": "/usr/share/fonts/noto/NotoTraditionalNushu-Regular.ttf", + "size": 104708, + "sha256": "a5560a6004c2f90184c47012833066031a6cf7327c6a828102f056d3aa90ee1c" + }, + { + "path": "/usr/share/fonts/noto/NotoZnamennyMusicalNotation-Regular.ttf", + "size": 45376, + "sha256": "b6ed2a11d2a653e14137a35e4c6fdaf5093434ac12964791ee195270828e7508" + }, + { + "path": "/usr/share/fonts/noto-cjk/NotoSansCJK-Black.ttc", + "size": 19629516, + "sha256": "3cffb10242b4b7e6edd439ebf3bd7e392345525e093ea08149e0a0158a1b5151" + }, + { + "path": "/usr/share/fonts/noto-cjk/NotoSansCJK-Bold.ttc", + "size": 20050760, + "sha256": "faa5f3656a78b2e2d450d27fe8382c778bc2b6bb5ea29c986664a6a435056ceb" + }, + { + "path": "/usr/share/fonts/noto-cjk/NotoSansCJK-DemiLight.ttc", + "size": 18245584, + "sha256": "73c01c7f28072016d44ca57893a7c3b2c4ca31d9662032c5b315164625f0dfe4" + }, + { + "path": "/usr/share/fonts/noto-cjk/NotoSansCJK-Light.ttc", + "size": 18160344, + "sha256": "872def437cf8b9c41bf1736da891e34a8c25b764c0c4a51418d3745570660f42" + }, + { + "path": "/usr/share/fonts/noto-cjk/NotoSansCJK-Medium.ttc", + "size": 18354360, + "sha256": "197d5e1e019faca33a4d55931c7d68b8056f3b97cb862049f5cb8de9efdfb8ce" + }, + { + "path": "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", + "size": 19484784, + "sha256": "b76b0433203017ca80401b2ee0dd69350349871c4b19d504c34dbdd80541690a" + }, + { + "path": "/usr/share/fonts/noto-cjk/NotoSansCJK-Thin.ttc", + "size": 16727128, + "sha256": "6efb3dfee32c261d6be3c2b1f89928afc1d18328499cddda4b1833338e7054b7" + }, + { + "path": "/usr/share/fonts/noto-cjk/NotoSerifCJK-Black.ttc", + "size": 25937552, + "sha256": "6e157ad56a601db8a9f19032aadf56d6ceb20db7acc22223dfa70d313fba76bc" + }, + { + "path": "/usr/share/fonts/noto-cjk/NotoSerifCJK-Bold.ttc", + "size": 27397052, + "sha256": "1505ee3b9c0890fae6302ee0e9c6fd74d690f4a55a6ace1d9944f3f6352d622d" + }, + { + "path": "/usr/share/fonts/noto-cjk/NotoSerifCJK-ExtraLight.ttc", + "size": 23276096, + "sha256": "556fdf24fee49c30a2507798aff0b39c79a6959c9773d21ac63afa85c9128bbb" + }, + { + "path": "/usr/share/fonts/noto-cjk/NotoSerifCJK-Light.ttc", + "size": 26370620, + "sha256": "0a8fad491c0c2bf29b5b87e1e338c24162752713dec35f35acb48743a29ce433" + }, + { + "path": "/usr/share/fonts/noto-cjk/NotoSerifCJK-Medium.ttc", + "size": 26674468, + "sha256": "16fe6d7230f82155748e10e47420dd661a899a200e05a77ee029ab1eb0fb5f29" + }, + { + "path": "/usr/share/fonts/noto-cjk/NotoSerifCJK-Regular.ttc", + "size": 26411360, + "sha256": "5d9c31a059600193c9d7968a998bde886ccdc77e934006ad243b41794c496a7d" + }, + { + "path": "/usr/share/fonts/noto-cjk/NotoSerifCJK-SemiBold.ttc", + "size": 26575144, + "sha256": "d55b3b7435148c54db0dd251982a400beb16724cbb7ce48c52088f2635e0d2bc" + } + ] + }, + "determinism": { + "PYTHONHASHSEED": "0", + "random_seed": 0 + } +} diff --git a/browser/oracle/requirements.in b/browser/oracle/requirements.in new file mode 100644 index 000000000..0bf25d3ed --- /dev/null +++ b/browser/oracle/requirements.in @@ -0,0 +1,15 @@ +scrapling[fetchers]==0.4.9 +iii-sdk==0.21.6 +iii-helpers==0.21.6 +curl-cffi==0.16.0 +playwright==1.60.0 +patchright==1.60.1 +browserforge==1.2.4 +lxml==6.1.1 +cssselect==1.5.0 +w3lib==2.4.1 +markdownify==1.2.3 +beautifulsoup4==4.15.0 +pillow==12.3.0 +tld==0.13.2 +orjson==3.11.9 diff --git a/browser/oracle/requirements.lock b/browser/oracle/requirements.lock new file mode 100644 index 000000000..050f42124 --- /dev/null +++ b/browser/oracle/requirements.lock @@ -0,0 +1,983 @@ +# This file was autogenerated by uv via the following command: +# scripts/update_oracle.sh +annotated-types==0.8.0 \ + --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 + # via pydantic +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ + --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f + # via + # httpx + # scrapling +apify-fingerprint-datapoints==0.15.0 \ + --hash=sha256:5776fe73feaa3910265cae55599552b098f3a716d8872c9a0c2295ff2bb680dc \ + --hash=sha256:fc9299b3136880f47b468897cd00ac622ad2c2a93e9cc9f972d10dbf5ed2b3aa + # via + # browserforge + # scrapling +beautifulsoup4==4.15.0 \ + --hash=sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7 \ + --hash=sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9 + # via + # -r oracle/requirements.in + # markdownify +browserforge==1.2.4 \ + --hash=sha256:05686473793769856ebd3528c69071f5be0e511260993e8b2ba839863711a0c4 \ + --hash=sha256:fb1c14e62ac09de221dcfc73074200269f697596c642cb200ceaab1127a17542 + # via + # -r oracle/requirements.in + # scrapling +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via + # curl-cffi + # httpcore + # httpx +cffi==2.1.1 \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via curl-cffi +click==8.4.2 \ + --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \ + --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 + # via + # browserforge + # scrapling +cssselect==1.5.0 \ + --hash=sha256:1d1aded98e82bdde447ded990a191fd6916177c4f0c914fb62eccd58e2ffcdcc \ + --hash=sha256:3cbe82dd7acbee9ba9e5723b5f9e4749826912f1fb31cd7f92aabed5fde15b15 + # via + # -r oracle/requirements.in + # scrapling +curl-cffi==0.16.0 \ + --hash=sha256:06b1c7e07af8ff7c4c5ce4086ea89cc582ebff9adff4a37cfffa5f5de5d5b943 \ + --hash=sha256:095fc36e4988736f31521d6fe0aa1f243dba22656b4818fc2fb3b7a547e7a9ba \ + --hash=sha256:182416f07d71a342240554fa62c22e591999b78c225b21d3fe27d9f807420dd6 \ + --hash=sha256:1efd99f7df6e32cbcef7d5d1a0136141da45ff850edf5fc1845c8bed1d06fb95 \ + --hash=sha256:226c038cfc85db5190c3d4ec1737a897e7651a45fc794ad384634633b1b5b92f \ + --hash=sha256:2c93c2f4cf5308f40b07516d57c5f499752387b12a4611420b665b1b958aaa87 \ + --hash=sha256:3a1c8a7469453d09b500c47c83945179c4dd31c327906791e54292a06fec080a \ + --hash=sha256:3c31e71bf68a9c02a279a184ec9c0ea7c80ce1ef4f1d35073fae3124eb3a7868 \ + --hash=sha256:6128021320f74999ec1216c1817b2c3adcb0f334d204add1ccf18e248bf7efcb \ + --hash=sha256:6c540b625979b618bff1339e998058c0a36fb2ef93c336b3ef4695c3dae6decd \ + --hash=sha256:75e5898b3066b64a68fa1eb72552a2e027d61c9d2020657ee2fc3e73d4b39db0 \ + --hash=sha256:93615d44f23e56c1256700c2e78de4b879310f39a5621828ea7e2a5ecc04bdda \ + --hash=sha256:98f98848aed5d1cb0d5393c46ab84acd73b160c21beb49d6fa612d3cff926478 \ + --hash=sha256:b00b423da8028eb6221e3b63bcd63d681150c07cee8b16000d1f7ea292731895 \ + --hash=sha256:c3dd33eaf267d017bfac09b4f71af1d94045dee5518ce18af380462e29f92fad \ + --hash=sha256:ce1f823bc5ce675291a7cf14781496775dfae9298638c25059f2565f6a58a704 \ + --hash=sha256:ce87f301b31147711c3aebc86fb7e16d8dc48f7e5df272c1bea760c687e28eef \ + --hash=sha256:d95c0deccc2184eeee7c2aa18d07de261184b418210995aabd0d29f98717a05c \ + --hash=sha256:e22a8212d830108e977ff394237f637238e265f5f65037d6c1ee71ea8cc03bcb \ + --hash=sha256:e52586a9dc4ed5e75faa39be0f30353b10cdc7410bad276beb013085e974bb44 \ + --hash=sha256:edd5f6e8f122157f4d2351b0b5e48e6a1c0677a2064da71451bb30ef57af19ba + # via + # -r oracle/requirements.in + # scrapling +greenlet==3.5.5 \ + --hash=sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537 \ + --hash=sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39 \ + --hash=sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277 \ + --hash=sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41 \ + --hash=sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2 \ + --hash=sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d \ + --hash=sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53 \ + --hash=sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e \ + --hash=sha256:19d59f068887d8c5907fc177f27683413ace3011b6ed646c0b309266e74a6502 \ + --hash=sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5 \ + --hash=sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc \ + --hash=sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759 \ + --hash=sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f \ + --hash=sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b \ + --hash=sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1 \ + --hash=sha256:2b70a766135540c472ac1393d57c2e1b4a2eb85bf526a1e41e6d096173a8cee5 \ + --hash=sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769 \ + --hash=sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0 \ + --hash=sha256:3134291427bb0f3526e9d90311988caf336eb43730e95244997a4fb15f45144f \ + --hash=sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da \ + --hash=sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76 \ + --hash=sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3 \ + --hash=sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e \ + --hash=sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476 \ + --hash=sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e \ + --hash=sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380 \ + --hash=sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef \ + --hash=sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18 \ + --hash=sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b \ + --hash=sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272 \ + --hash=sha256:523bb8e27614d77101ea7a8cf59f8d91219b72d5c29f6a038c92b50828bfa8d0 \ + --hash=sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053 \ + --hash=sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07 \ + --hash=sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387 \ + --hash=sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52 \ + --hash=sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed \ + --hash=sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95 \ + --hash=sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c \ + --hash=sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad \ + --hash=sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f \ + --hash=sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db \ + --hash=sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328 \ + --hash=sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8 \ + --hash=sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71 \ + --hash=sha256:740e544169527b82695ce76af2f7ad6f030904658f2f3921a1d245771fb88cfc \ + --hash=sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864 \ + --hash=sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0 \ + --hash=sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1 \ + --hash=sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b \ + --hash=sha256:816230f469381ad0a43abc9fa8dda5a699e32fb78958dde32ded93213b70a667 \ + --hash=sha256:86c5113d698cb8d927b2750bb1f1d59eefe3a37e0e0217491aee29a7f84ef52c \ + --hash=sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c \ + --hash=sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926 \ + --hash=sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc \ + --hash=sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd \ + --hash=sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007 \ + --hash=sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6 \ + --hash=sha256:9ff00e12102358292087274dfb1669132387ff6e7920ebf9d85f4826ce0d3a56 \ + --hash=sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0 \ + --hash=sha256:a5433cf291e0ef9114bd14d0d824db6e5e4a43033234bca48181a9597acca07b \ + --hash=sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53 \ + --hash=sha256:abc8bc8d9f935cd685457545b6a53863a877fdc12c2c0f5ee9beee18d9db139c \ + --hash=sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c \ + --hash=sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474 \ + --hash=sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa \ + --hash=sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61 \ + --hash=sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206 \ + --hash=sha256:c69bed34470abfcd456984fdadaa18e62169af4480335c45f3c32d1d9c12e638 \ + --hash=sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9 \ + --hash=sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874 \ + --hash=sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d \ + --hash=sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8 \ + --hash=sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae \ + --hash=sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0 \ + --hash=sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773 \ + --hash=sha256:f1e2db190db51c17433eee424803818cf0670bf049d9cfe0dd07be111d1aa7c4 \ + --hash=sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552 \ + --hash=sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42 \ + --hash=sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b + # via + # patchright + # playwright +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via httpcore +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via iii-helpers +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 + # via + # anyio + # httpx +iii-helpers==0.21.6 \ + --hash=sha256:1b7ae100d396ad3204ed86f03394173c59e307cb0a62421f632e93eb5819eba6 \ + --hash=sha256:7b156dca6e91c1feef56ea4a122e36da0f6721ad290cb0375226b8dfad9ec932 + # via + # -r oracle/requirements.in + # iii-sdk +iii-sdk==0.21.6 \ + --hash=sha256:7367b024b6735a4437c3c295b301505628a1bab05b2ffb812bfa6e529549920f \ + --hash=sha256:dbadf5b2ad7c9acce84becb61190a8a6edbad66bb142092cdf9776b360b8c202 + # via -r oracle/requirements.in +lxml==6.1.1 \ + --hash=sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2 \ + --hash=sha256:07a4a68e286ee7a1ed7dfb8af83e615757c0ccfe9f18c6b4ea6771388d9ba8c9 \ + --hash=sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60 \ + --hash=sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c \ + --hash=sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7 \ + --hash=sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a \ + --hash=sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83 \ + --hash=sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072 \ + --hash=sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8 \ + --hash=sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462 \ + --hash=sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0 \ + --hash=sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085 \ + --hash=sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f \ + --hash=sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30 \ + --hash=sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1 \ + --hash=sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77 \ + --hash=sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740 \ + --hash=sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b \ + --hash=sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c \ + --hash=sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621 \ + --hash=sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e \ + --hash=sha256:32ab449a5486f6c758e849bb86710d0e45edc24a04e250c01555f8f5653958f8 \ + --hash=sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca \ + --hash=sha256:34c2d737beabfe35baada43941ed519251e9a12e779031496bcd5d539fcfd730 \ + --hash=sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245 \ + --hash=sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1 \ + --hash=sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004 \ + --hash=sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d \ + --hash=sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52 \ + --hash=sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5 \ + --hash=sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf \ + --hash=sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc \ + --hash=sha256:441dd227fa0690eb9fc81edabc63cdcefc212bba99b906dcf6e32cc1a9d3e533 \ + --hash=sha256:469e3618338bd7ab5beb412d2439825479fcf0dab99e394ca563dbc4eaf6c834 \ + --hash=sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947 \ + --hash=sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a \ + --hash=sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2 \ + --hash=sha256:53c909b62a0532183542fed00c5a7218258c56292d409bc789886fe1cb04c438 \ + --hash=sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc \ + --hash=sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea \ + --hash=sha256:55b03549819867ea141c0202242c4816c82e52ec36e7e648db9d8da5a3dc3ed6 \ + --hash=sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e \ + --hash=sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c \ + --hash=sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383 \ + --hash=sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955 \ + --hash=sha256:5bec7d03d78d853597d6107854c2310ce3f761fd218fe9fe91d5101fcf6c2efe \ + --hash=sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074 \ + --hash=sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c \ + --hash=sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a \ + --hash=sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb \ + --hash=sha256:639f6c857d91d9be29bd7502348d6736dab168b54b5158cd899abf11684dc186 \ + --hash=sha256:640f97d43d867bcb9c75b3af013b64850756b746cb6bce8ace83b70da3abba9d \ + --hash=sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1 \ + --hash=sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f \ + --hash=sha256:6689e828a94eee4f139408c337bb198e014724bb8a8c26d3cfac49d119ed69a6 \ + --hash=sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736 \ + --hash=sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6 \ + --hash=sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2 \ + --hash=sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b \ + --hash=sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7 \ + --hash=sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14 \ + --hash=sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009 \ + --hash=sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca \ + --hash=sha256:76447f65250ed2501ead1a1552f5ce8edff159a86f308348e6a9c4acb5e1f1b4 \ + --hash=sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635 \ + --hash=sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee \ + --hash=sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e \ + --hash=sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9 \ + --hash=sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603 \ + --hash=sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08 \ + --hash=sha256:83b6b30eb131da7a75b601f28c5d6971e6ed3e887919bf6b6a1ad3c2df289080 \ + --hash=sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525 \ + --hash=sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5 \ + --hash=sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f \ + --hash=sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067 \ + --hash=sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e \ + --hash=sha256:8be8ad51249698103d24b0571df35a10990fbe93dd043b6c024172189485f5e3 \ + --hash=sha256:8d43ca737b20e106e4aebc42b2f3ae19f00ba63d7eb731698ee083d72d15646f \ + --hash=sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485 \ + --hash=sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13 \ + --hash=sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383 \ + --hash=sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315 \ + --hash=sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e \ + --hash=sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c \ + --hash=sha256:9f76acfb5f68ba982635a53fd985a8044be98a35b43232c2a1ee235ffab3e1dd \ + --hash=sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099 \ + --hash=sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660 \ + --hash=sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510 \ + --hash=sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a \ + --hash=sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b \ + --hash=sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5 \ + --hash=sha256:aae97dfdb60715c164419ac2532a76d013c3918a665eb6cb7288098b5f349aaf \ + --hash=sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28 \ + --hash=sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00 \ + --hash=sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef \ + --hash=sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1 \ + --hash=sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955 \ + --hash=sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590 \ + --hash=sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137 \ + --hash=sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf \ + --hash=sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40 \ + --hash=sha256:bdebcc8a75d38c7598dfb2c9ed852d7a9eb4a10d6e2d0764b919b802bf32ac88 \ + --hash=sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e \ + --hash=sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840 \ + --hash=sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2 \ + --hash=sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca \ + --hash=sha256:c674693f055fa2495de12292cb45e9944199d8eaef5a2dec45175c7c61cb73e3 \ + --hash=sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465 \ + --hash=sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc \ + --hash=sha256:c9a4b821dc7055bf9e05ff5719e18ec501f75c0f0bbfabd573b277559780833d \ + --hash=sha256:c9f79d5325907f13e1be0b3e4dacc1049d1dffc4aeee3c995284bea5fe0fab7d \ + --hash=sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa \ + --hash=sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a \ + --hash=sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a \ + --hash=sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206 \ + --hash=sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e \ + --hash=sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785 \ + --hash=sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8 \ + --hash=sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a \ + --hash=sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b \ + --hash=sha256:e07c65f443c887bbcf31cc1771d932ecc192a5273943589b3c7572b749f1ffb2 \ + --hash=sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6 \ + --hash=sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6 \ + --hash=sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354 \ + --hash=sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818 \ + --hash=sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84 \ + --hash=sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909 \ + --hash=sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038 \ + --hash=sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d \ + --hash=sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2 \ + --hash=sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf \ + --hash=sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc \ + --hash=sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d \ + --hash=sha256:ffecec8eb889b58ba9be5b95fb1cc78e22ea8eedea38e8736a1568fe1979250e + # via + # -r oracle/requirements.in + # scrapling +markdownify==1.2.3 \ + --hash=sha256:1a176f05522c8a2cb1dd3ab9d307dcdadbed5c26ae717855bfc42b3b6d38d937 \ + --hash=sha256:a189a0bedfd14009030fde5f85bb6f77c56897cb839b5c25315dd7d4e3e290ba + # via -r oracle/requirements.in +msgspec==0.21.1 \ + --hash=sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca \ + --hash=sha256:0d1009f6715f5bff3b54d4ff5c7428ad96197e0534e1645b8e9b955890c84664 \ + --hash=sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90 \ + --hash=sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2 \ + --hash=sha256:1bf17cbd7b28a5dffc7e764c654eed8ccde5e0f1de7970628608304640d4ce4e \ + --hash=sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff \ + --hash=sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c \ + --hash=sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697 \ + --hash=sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61 \ + --hash=sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a \ + --hash=sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb \ + --hash=sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898 \ + --hash=sha256:4692b7c1609155708c4418f88e92f63c13fdf08aa095c84bae82bad75b53389b \ + --hash=sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5 \ + --hash=sha256:49880fd20fdbcfe1b793f07dd83f12572bab679c9800352c8b2240289aa46a06 \ + --hash=sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b \ + --hash=sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb \ + --hash=sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df \ + --hash=sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea \ + --hash=sha256:5d2d4116ebe3035a78d9ec76e99a9d64e5fa6d44fe61a9c5de7fd1acf54bcc69 \ + --hash=sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7 \ + --hash=sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee \ + --hash=sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e \ + --hash=sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f \ + --hash=sha256:6badc03b9725352219cca017bfe71c61f2fbd0fb5982b410ac17c97c213deb30 \ + --hash=sha256:72d9cd03241b8b2edb2e12dcc66c500fa480d8cbd71a8bac105809d468882064 \ + --hash=sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63 \ + --hash=sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66 \ + --hash=sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920 \ + --hash=sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a \ + --hash=sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75 \ + --hash=sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa \ + --hash=sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f \ + --hash=sha256:ae0162e22849a5e91eaad907766525107523b0daea3df267a9fcb5ba4e0936ae \ + --hash=sha256:b504b6e7f7a22a24b27232b73034421692147865162daaec9f3bf62439007c87 \ + --hash=sha256:c6faffe5bb644ec884052679af4dfd776d4b5ca90e4a7ec7e7e319e4e6b93a6e \ + --hash=sha256:d3124010b3815451494c85ff345e693cb9fe5889cfcbbef39ed8622e0e72319c \ + --hash=sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251 \ + --hash=sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d \ + --hash=sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93 \ + --hash=sha256:dd677e3001fdfed9186de72eab434da2976303cd5eb9550921d3d0c3e3e168ce \ + --hash=sha256:ed2ab278200e743a1d2610a4e0c8fc74f6cecb8548544cdec43f927bd9265238 \ + --hash=sha256:ee9e3f11fa94603f7d673bf795cfa31b549c4a2c723bc39b45beb1e7f5a3fb99 \ + --hash=sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521 \ + --hash=sha256:f041a2279f31e3a53319005e4d60ba77c085cfcbe394cdc7ce803c2d01fe9449 \ + --hash=sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847 \ + --hash=sha256:f667b90b37fad734a91671abd68e0d7f4d066862771b87e91c53996dcb7a9027 \ + --hash=sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484 \ + --hash=sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f + # via scrapling +opentelemetry-api==1.44.0 \ + --hash=sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a \ + --hash=sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef + # via + # iii-helpers + # iii-sdk + # opentelemetry-sdk + # opentelemetry-semantic-conventions +opentelemetry-sdk==1.44.0 \ + --hash=sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b \ + --hash=sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad + # via iii-helpers +opentelemetry-semantic-conventions==0.65b0 \ + --hash=sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb \ + --hash=sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60 + # via opentelemetry-sdk +orjson==3.11.9 \ + --hash=sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4 \ + --hash=sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62 \ + --hash=sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979 \ + --hash=sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0 \ + --hash=sha256:0b34789fa0da61cf7bef0546b09c738fb195331e017e477096d129e9105ab03d \ + --hash=sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a \ + --hash=sha256:115ab5f5f4a0f203cc2a5f0fb09aee503a3f771aa08392949ab5ca230c4fbdbd \ + --hash=sha256:135869ef917b8704ea0a94e01620e0c05021c15c52036e4663baffe75e72f8ce \ + --hash=sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1 \ + --hash=sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180 \ + --hash=sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980 \ + --hash=sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697 \ + --hash=sha256:231742b4a11dad8d5380a435962c57e91b7c37b79be858f4ef1c0df1a259897e \ + --hash=sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1 \ + --hash=sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09 \ + --hash=sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd \ + --hash=sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470 \ + --hash=sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b \ + --hash=sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877 \ + --hash=sha256:34fd2317602587321faab75ab76c623a0117e80841a6413654f04e47f339a8fb \ + --hash=sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd \ + --hash=sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe \ + --hash=sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97 \ + --hash=sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c \ + --hash=sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5 \ + --hash=sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021 \ + --hash=sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362 \ + --hash=sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206 \ + --hash=sha256:4da3c38a2083ca4aaf9c2a36776cce3e9328e6647b10d118948f3cfb4913ffe4 \ + --hash=sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218 \ + --hash=sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9 \ + --hash=sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f \ + --hash=sha256:53b50b0e14084b8f7e29c5ce84c5af0f1160169b30d8a6914231d97d2fe297d4 \ + --hash=sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02 \ + --hash=sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be \ + --hash=sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972 \ + --hash=sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586 \ + --hash=sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2 \ + --hash=sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124 \ + --hash=sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa \ + --hash=sha256:71f3db16e69b667b132e0f305a833d5497da302d801508cbb051ed9a9819da47 \ + --hash=sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c \ + --hash=sha256:8697ab6a080a5c46edaad50e2bc5bd8c7ca5c66442d24104fa44ec74910a8244 \ + --hash=sha256:87e4d4ab280b0c87424d47695bec2182caf8cfc17879ea78dab76680194abc13 \ + --hash=sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10 \ + --hash=sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677 \ + --hash=sha256:97d0d932803c1b164fde11cb542a9efcb1e0f63b184537cca65887147906ff48 \ + --hash=sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4 \ + --hash=sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624 \ + --hash=sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49 \ + --hash=sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0 \ + --hash=sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b \ + --hash=sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c \ + --hash=sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2 \ + --hash=sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db \ + --hash=sha256:ace6c58523302d3b97b6ac5c38a5298a54b473762b6be82726b4265c41029f92 \ + --hash=sha256:b3afcf569c15577a9fe64627292daa3e6b3a70f4fb77a5df246a87ec21681b94 \ + --hash=sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e \ + --hash=sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61 \ + --hash=sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882 \ + --hash=sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff \ + --hash=sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254 \ + --hash=sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f \ + --hash=sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32 \ + --hash=sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff \ + --hash=sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673 \ + --hash=sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291 \ + --hash=sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0 \ + --hash=sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c \ + --hash=sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9 \ + --hash=sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f \ + --hash=sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e \ + --hash=sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7 \ + --hash=sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81 + # via + # -r oracle/requirements.in + # scrapling +patchright==1.60.1 \ + --hash=sha256:023945a2fd30219a284721ca36385bd44075ae7b53071dc1da38036b6dbe88ec \ + --hash=sha256:05b98a6afdbe7e6645fe223009c47cc8e7859df55fd8ce9d8a9925b3389b0ee1 \ + --hash=sha256:20bd806df2469b451ccd2ea10f5f944ceb0e0d83c716f5752b0c956c1ee59476 \ + --hash=sha256:51b306ed55cd58f1bca24641458f5c9f7e86a1f1727dcffdead669cfe4c0a485 \ + --hash=sha256:547e7bfb813102309789cc42933780e5fdf7c4727de59fb2791e64bd1298a7f3 \ + --hash=sha256:9fd15a64c0ca80740dc2a3f41cda336a06a2ed6068d0ab893172654290b06e6b \ + --hash=sha256:e9492100d4e2a85ff92fc3a668dd16dee03f21df6e559c7b9f7c71e86ff48c6b \ + --hash=sha256:f795728c1e27fc226dbe203c1aec713a537f01be963474fe0f3691f5e6457f9f + # via + # -r oracle/requirements.in + # scrapling +pillow==12.3.0 \ + --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \ + --hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \ + --hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \ + --hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \ + --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \ + --hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \ + --hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \ + --hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \ + --hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \ + --hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \ + --hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \ + --hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \ + --hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \ + --hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \ + --hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \ + --hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \ + --hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \ + --hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \ + --hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \ + --hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \ + --hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \ + --hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \ + --hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \ + --hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \ + --hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \ + --hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \ + --hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \ + --hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \ + --hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \ + --hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \ + --hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \ + --hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \ + --hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \ + --hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \ + --hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \ + --hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \ + --hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \ + --hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \ + --hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \ + --hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \ + --hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \ + --hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \ + --hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \ + --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \ + --hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \ + --hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \ + --hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \ + --hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \ + --hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \ + --hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \ + --hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \ + --hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \ + --hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \ + --hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \ + --hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \ + --hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \ + --hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \ + --hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \ + --hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \ + --hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \ + --hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \ + --hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \ + --hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \ + --hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \ + --hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \ + --hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \ + --hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \ + --hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \ + --hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \ + --hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \ + --hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \ + --hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \ + --hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \ + --hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \ + --hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \ + --hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \ + --hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \ + --hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \ + --hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \ + --hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \ + --hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \ + --hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \ + --hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \ + --hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \ + --hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \ + --hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \ + --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7 + # via -r oracle/requirements.in +playwright==1.60.0 \ + --hash=sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e \ + --hash=sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705 \ + --hash=sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5 \ + --hash=sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353 \ + --hash=sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7 \ + --hash=sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537 \ + --hash=sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02 \ + --hash=sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7 + # via + # -r oracle/requirements.in + # scrapling +protego==0.6.2 \ + --hash=sha256:714de21d82527c9be900066c3211b266985dd6a19b6e70c57e033fc1a589f3ff \ + --hash=sha256:88ff004544ce44e61269cc6f8735f7837d12e09bb77619fc94fbb26b72e5d137 + # via scrapling +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +pydantic==2.13.4 \ + --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \ + --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6 + # via + # iii-helpers + # iii-sdk +pydantic-core==2.46.4 \ + --hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \ + --hash=sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262 \ + --hash=sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda \ + --hash=sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0 \ + --hash=sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e \ + --hash=sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b \ + --hash=sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594 \ + --hash=sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29 \ + --hash=sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2 \ + --hash=sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c \ + --hash=sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d \ + --hash=sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398 \ + --hash=sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d \ + --hash=sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3 \ + --hash=sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f \ + --hash=sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb \ + --hash=sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7 \ + --hash=sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5 \ + --hash=sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9 \ + --hash=sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462 \ + --hash=sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4 \ + --hash=sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b \ + --hash=sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d \ + --hash=sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df \ + --hash=sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2 \ + --hash=sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0 \ + --hash=sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519 \ + --hash=sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd \ + --hash=sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7 \ + --hash=sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac \ + --hash=sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6 \ + --hash=sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565 \ + --hash=sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898 \ + --hash=sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb \ + --hash=sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928 \ + --hash=sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6 \ + --hash=sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3 \ + --hash=sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a \ + --hash=sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596 \ + --hash=sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987 \ + --hash=sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e \ + --hash=sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d \ + --hash=sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712 \ + --hash=sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008 \ + --hash=sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd \ + --hash=sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1 \ + --hash=sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be \ + --hash=sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea \ + --hash=sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292 \ + --hash=sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33 \ + --hash=sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3 \ + --hash=sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4 \ + --hash=sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b \ + --hash=sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 \ + --hash=sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac \ + --hash=sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7 \ + --hash=sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d \ + --hash=sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf \ + --hash=sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4 \ + --hash=sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc \ + --hash=sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15 \ + --hash=sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3 \ + --hash=sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b \ + --hash=sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914 \ + --hash=sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04 \ + --hash=sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c \ + --hash=sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b \ + --hash=sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9 \ + --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce \ + --hash=sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4 \ + --hash=sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a \ + --hash=sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f \ + --hash=sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424 \ + --hash=sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894 \ + --hash=sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9 \ + --hash=sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76 \ + --hash=sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201 \ + --hash=sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb \ + --hash=sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109 \ + --hash=sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4 \ + --hash=sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848 \ + --hash=sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526 \ + --hash=sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0 \ + --hash=sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01 \ + --hash=sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458 \ + --hash=sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e \ + --hash=sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba \ + --hash=sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a \ + --hash=sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39 \ + --hash=sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c \ + --hash=sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000 \ + --hash=sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b \ + --hash=sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf \ + --hash=sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4 \ + --hash=sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd \ + --hash=sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28 \ + --hash=sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9 \ + --hash=sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30 \ + --hash=sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983 \ + --hash=sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1 \ + --hash=sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76 \ + --hash=sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5 \ + --hash=sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4 \ + --hash=sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7 \ + --hash=sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c \ + --hash=sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066 \ + --hash=sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3 \ + --hash=sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02 \ + --hash=sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 \ + --hash=sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50 \ + --hash=sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76 \ + --hash=sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49 \ + --hash=sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b \ + --hash=sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d \ + --hash=sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7 \ + --hash=sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4 \ + --hash=sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c \ + --hash=sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e \ + --hash=sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff \ + --hash=sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae + # via pydantic +pyee==13.0.1 \ + --hash=sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8 \ + --hash=sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228 + # via + # patchright + # playwright +scrapling==0.4.9 \ + --hash=sha256:00c7fae4641d948fb26486fd1da22143e5296dc86ef821b8426c0c2854c529ef \ + --hash=sha256:e08afab736e5bd3337173e524fee99aea3073476e86d44d942af4bef9496a499 + # via -r oracle/requirements.in +six==1.17.0 \ + --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ + --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 + # via markdownify +soupsieve==2.9.2 \ + --hash=sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74 \ + --hash=sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823 + # via beautifulsoup4 +tld==0.13.2 \ + --hash=sha256:9b8fdbdb880e7ba65b216a4937f2c94c49a7226723783d5838fc958ac76f4e0c \ + --hash=sha256:d983fa92b9d717400742fca844e29d5e18271079c7bcfabf66d01b39b4a14345 + # via + # -r oracle/requirements.in + # scrapling +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # anyio + # beautifulsoup4 + # opentelemetry-api + # opentelemetry-sdk + # opentelemetry-semantic-conventions + # pydantic + # pydantic-core + # pyee + # scrapling + # typing-inspection +typing-inspection==0.4.4 \ + --hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \ + --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147 + # via pydantic +w3lib==2.4.1 \ + --hash=sha256:40930132907e68de906a5b89331ab8c8ff4f01bd35b5539ef7896017d814138d \ + --hash=sha256:8dd69ee39ff6398d708c793abc779c334a69bac7cee1cdf71736c669ed6be864 + # via + # -r oracle/requirements.in + # scrapling +websockets==17.0.1 \ + --hash=sha256:02ed63bf26dda9fa27df730a41f6664586c4ee05972c8fb667ce1725b3fd13d3 \ + --hash=sha256:02f0b037a737d0cb0c33866c97bcd1a0b73170dfbf42d69d8fb86f51002fd5ae \ + --hash=sha256:038cfad5d5417f8bb09295abe986029a26d22f34bda622ccc79b670efd4dab56 \ + --hash=sha256:07abc3bd196a48af476a82fd47f3f79a6a3f70937a9f930cef703cfa0c9d83b6 \ + --hash=sha256:07d78a509c3333f5908c83d7f78144ea68a6c9ec28110f5c54d81d8fcdc262c4 \ + --hash=sha256:0b52c76b8a870b141b7ca0705289452183ce7a523101954ccfe29a25986a673f \ + --hash=sha256:10b1587c599fa0f2c89154587c80e0fda98ade6c9fa8c0260a2823fb1800b685 \ + --hash=sha256:10f461191125c63902ea7394ae9e752b1b5785641850c1d365bb30b0f88bc53f \ + --hash=sha256:15920057a6b723f84734f0641403bca163a4b176e5af809ee4f0c4a1e75e9fed \ + --hash=sha256:17ac37716c0244e82c9e384c41653c090b1864c6610224ca3857e7f7b58fce10 \ + --hash=sha256:18ded646ce98cdd3c0235825b3252f1df55765ba49b616bb10282f758667b4d0 \ + --hash=sha256:1b363bfd72a52c0658a3154a4cff219f15a474b35a235057d38853bf151acce7 \ + --hash=sha256:1bdd8c4be420905dd732e00dcd669852d8128cc723efa585a0c0e51adb00a28a \ + --hash=sha256:1d4cf7e8e5b8b1fa40758ac7524843a00237b124ab217e227542cafcfeb7a946 \ + --hash=sha256:1df81d174c1561292de9e40b141cafc04f69077272f6c352afe1d743e20810df \ + --hash=sha256:20a92f78ac8250984ed459faa9ca48c285adbfc0038ddc3fdac6046990a9c9ed \ + --hash=sha256:22bd00f8bae2bccdb5dbe41e20f58ba44ca9fff0b4b561aaf39099c35da762ed \ + --hash=sha256:2437d4ca208cc0f246d3a2297ae7474b4ba18261aaf5b9c79c84c031ecf348e1 \ + --hash=sha256:246927ae9ae06ca0d42a483a4bdb80d4862e1ee5b4cab37c354a5e1ad8356448 \ + --hash=sha256:2503c7e2a5049a12d5dac917a46d5d52591283a766165b8176bb167560421b38 \ + --hash=sha256:2604de7228506b13a44a256a9d223943340c0e725af5d367dc068e192b027761 \ + --hash=sha256:28012a54510fe8301bb893ef143cec30a2780a2d3bc20b7bbdf4379d7a63945d \ + --hash=sha256:2a855b6dfe21c4d3420be265ae031829ba8ba0be0ea350d9f7c3ef30ae63ebe2 \ + --hash=sha256:2abb1ba0a5133b7d2ef3c1c9f4b0c1e8a101012dce0b594ab2b2888d9a64820e \ + --hash=sha256:2b3f3020171202b135ca078e20434977c6b2b02af647130d6980c9e39b9462e3 \ + --hash=sha256:2bc14b481e05e331811108daa1aeb41a5e237a5564ef2f02ec5a356a0f102f78 \ + --hash=sha256:2fa2cb465a131c347ba6717a78c887746e73edb1c131d01c982d6ef0d68b82e0 \ + --hash=sha256:409d93efcaa14f7a99592c5baaef5ec6ca94fba0f5aec1a86f693977c69c9c1c \ + --hash=sha256:41d6aa06b5ab832aee72fedf47a149535b121ac900b6bb4d3fe14712afac9a79 \ + --hash=sha256:49266e4488309b38783257293a38298942b9a03aa106fcb45195377a77c0c1e2 \ + --hash=sha256:4d1d99db29b5444e3982f1ce2ba8a833508ad44b2f1fbd0bd99e81d825c0b461 \ + --hash=sha256:4d41c0a1d47a478bc432b3b9068097bee1ce0c5b19327ea6f75c2ab34ab1f2fb \ + --hash=sha256:5033ffe6804dd53afafa7d08e8c3eef2d2431f34d58ca30507a8442dd04a033a \ + --hash=sha256:53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e \ + --hash=sha256:54cdcaa56f5d3eafd57058f0fa4a3de93a310b43a3c4699f06efc4c0bd054a5a \ + --hash=sha256:5508f38c98ac29def9e747b87543b008a58b075df6da70b2cf2e0b47073d33bb \ + --hash=sha256:55383d8177b3c99fd873ee5db0e0193f4c1dd4a3feaccf1a4a03c1b7cf539cac \ + --hash=sha256:55b12e47dcee83673a40d07686cfb6f9d6dfc285976ade9463f61d2bef3fad22 \ + --hash=sha256:5661f868ef191d33dfc6a0cc7c5b3d495f0cc8bb3f8b30d87bda8755c61c95f5 \ + --hash=sha256:57d2ee9b24b404ce75f3814f92073c0ed88106c950148d2427fe8d25ca254d1f \ + --hash=sha256:599b03beb77633bffc095334338fad79cafc2b01fbd58953838130a9ae967d7b \ + --hash=sha256:5baa9bc0dfbae8c507e51c8cf1b6d4628086f7a87bbd3a9952bd5f035451f1cc \ + --hash=sha256:5f33a649bfcb8312524173cc4bbafa7dbb236e18eee9aa31a1d324ca0ddda28c \ + --hash=sha256:6740be6d1bab69f08ab52cb15b08f76c143b6fe61c580ba62bd929f3ab7a1d42 \ + --hash=sha256:6a434e59962a4fb9016bea327e1d14d6cd67670ecfb8942b4f4a0c24036634ce \ + --hash=sha256:6db9e5bf3649ab506c6ae8a3ac85a00fb1ae3816d75962771b2df8adbc5d40d2 \ + --hash=sha256:6fd88365da261c53d3e943fb37e0d0721b9cde119f6b2e3fc84369b6ab234d63 \ + --hash=sha256:7002d5f9e1c3ddd991cdfdbfee18cc8c8b196b2445022892badacd6cb338bbbc \ + --hash=sha256:70d438268e49f1a4bd096b6b6f7010f3ab48b5db2574dbf7d8c864c46ce7a06a \ + --hash=sha256:72d7f2a5aeb4e82daa4ee18f125b4277f427033359be5c745ad709608446cc2c \ + --hash=sha256:733e3cc7171fa1b899edbe725ef9382d0e960657dc1fd933f3281ae910c01dab \ + --hash=sha256:734d20364dc2cfe03674883cafcf580b6e431c5ce42b476312b9285310230cf9 \ + --hash=sha256:759adeb5b0c5775b563254ec63b5b79089fc0045b479143a0b1b8c0ebaae1253 \ + --hash=sha256:769ce7e2acfd9a89f2bed3a9c0da229459516bbc00bd4c9e2ca492c613ae4861 \ + --hash=sha256:810cb3fb5fa6e447216f4e82d9a85cb8aed0929ae3538153ddfe8a6e3121a58d \ + --hash=sha256:81ce19c6046ace11da7001781be7317bb1dc389f399af4b2ed962190f76f9add \ + --hash=sha256:846a4a8b0833e3cad57523d9e3bd50ec8ea05ab9d06c582f82a1340ba096af5f \ + --hash=sha256:872273e629ca7e3d35f16a2dc6ede84e1d5c831e616b8277de6e4f83114e7c58 \ + --hash=sha256:8848c207049ad49d318e5f64a3d4d7bb189f8328d0d98e65647788f2a085785c \ + --hash=sha256:884af729b8ab50486acd94d9768c2b60914bf39b579ebba0a5cb73bfdfd61fd2 \ + --hash=sha256:8c07f145d0b9e90cbd96035f31fb79199aef4da1872854e36ebeb258e3d57594 \ + --hash=sha256:8cd3369e42c0246afaf9d669cfc19797e3a49e8c0a639544459c57597108b966 \ + --hash=sha256:8e387adb0c692c6b5571bdeafc8ac9d1901ea30f10309134780b16ecd35e6605 \ + --hash=sha256:90246fa9e6cb192a778ce6ce024057ec54317a894db7899c922dcdc1f4cbf6a5 \ + --hash=sha256:90973a3a00f23afdfd1c9b06fb84289bf0220f247ef8a62501a1967c7af54f7b \ + --hash=sha256:9493314a99e599163c854fb5900ad7f7ea38c5cb9d9103aa30b3c6b8181c01fa \ + --hash=sha256:9f7747d3daa41a11f25f7cca5dc988fc51da97b311bed4c9d843860f79779283 \ + --hash=sha256:a39ce3a7b0e6059be093213d637963101380157bcbad355916738fafb490698d \ + --hash=sha256:a60fa1a25cca1bcc2bf87b8d6be37a741f0a3239fb5e9cfb7a37173b68ffcf87 \ + --hash=sha256:a68e604c6d1b0338e46652e2688cbce8096ad9c03548b075fda9e2ea19a9b7dd \ + --hash=sha256:a8af570fc29cd998a921c7131c8ac81d9434466d6d25300cb12a690fb56a8a08 \ + --hash=sha256:aadc298969ad229d8e3029fc5cc751fdad286696230f9cf014e90ff9cd8e6ea0 \ + --hash=sha256:ab56439c9f74c52770690c7b2f616b3bf775cb3920453ee355ac765c032d8bbf \ + --hash=sha256:ab9f962a5b64a5c3c845d556b7dc4e6fb683f7b67179f8205e814bb2e0213ffe \ + --hash=sha256:afbce6e3f0fac32dc87c2a0d84869d1a706460d64f39f3889386413e6e4d3d26 \ + --hash=sha256:b3ff0ad440ad52dda64138f16895f66403f40192365e39b1010e889f289746b0 \ + --hash=sha256:b580794e926cab7ff42ee4371ef14e0b22cb2bb722a607f77769136468f49a3f \ + --hash=sha256:b85b960a4507b0714c0a1246d031be9118d908ee974dc085257297a955205f1d \ + --hash=sha256:b98860aefbd3d9bc8e3c7f0eefb83b11142b16110739c68cd33d3b4d6e84e536 \ + --hash=sha256:bb31f42ea095ea826463c770829aa188a86c9a5c976b1467cbbf583c811de833 \ + --hash=sha256:bc0bca48ba24c6c866847fd20478a51dd547fa0ad258dab9615c414ec534bbc0 \ + --hash=sha256:bd1470d2c53fe53269bf5619da7725d30dd9b9693f1689f7a85eab8dea734442 \ + --hash=sha256:c09e097d0e46e3c289bedab9a475ae344b70c30ff5646e46af22b4e6fdc97b21 \ + --hash=sha256:c1bec5d6a19f5fbe87e4940739cfc65e7bb53d8b353e1029b8037a1653b321bc \ + --hash=sha256:c1c118a6b0e25bfc9a6802075d748fa6321714ffbdf3c88d29d9a0e3c7386c75 \ + --hash=sha256:c23e532c8a2325a1e7486de8763a60dc43e83f01bcaeca07e3ba79652c156db1 \ + --hash=sha256:c356dbddab0a529ed7574f78f559d75a223735c321c28f6f587fbf02b11ed301 \ + --hash=sha256:c38515cb54902f7e97d0239e81ef46c4444f9475f4807fb9bbdb789b4089abcf \ + --hash=sha256:c395bda8e7d8f51a02e80261fb57127979e5c472675d9a96b2860619ad47da48 \ + --hash=sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345 \ + --hash=sha256:cd526c8228e759c1006c4b7c9ac71dc4e925ced1a6a6a5a8e94643709738f63e \ + --hash=sha256:cddc675ec31bca65473321f9a9794e488b43b3b8de5d02c8ef4810c5d5792163 \ + --hash=sha256:cffc84ddec6da7f447677266fee2a3c40ecc78172f00752aa1150b8a8d65df1d \ + --hash=sha256:d41e9845514754a42d1d83b2fca9d27fee2ca7b3b0bee6843ba5a9bb2b6e25ac \ + --hash=sha256:d69fd559f9f0e8a52d2fce6f04ee143f86e70df0a189cd95164eddac599e810f \ + --hash=sha256:d7d72843691f50b91127c50688df10cb72ec6f4c4b1d7e2c11ab33b16acf8e51 \ + --hash=sha256:d9aac6081513f02eac3f8caace800dbfc5c608b69e4a7bef69e414eabfc95aa1 \ + --hash=sha256:dbfae8e75b342e31fc6fd1a8bbb393b7cbb91d6cfd581650300a94381e7b7e2b \ + --hash=sha256:e8208f2729cba030ff872a92064c97584eeb9502f53d32a05a0f05d5a17ca6c6 \ + --hash=sha256:e95e321d0d763f2b6633512605f6112ebd70d5746f3ce05c941909d4a25233f2 \ + --hash=sha256:e98ec9ec61cce5bc4b8b218322ad090b0994eb060bb04da704c62ef0a3d864e6 \ + --hash=sha256:eab6de8a98b9a7772cf686d00b4de439fc7efb8ab05ae106ef227291d06f87c5 \ + --hash=sha256:efe0ae052a8d023b87198921e8a7ce1dc7768816bcd2fbc20df171ac73a04891 \ + --hash=sha256:f11a398d8170b7ac5000baf7f258dcda579ef3ea744e0cc6a165e0dfbc0d3198 \ + --hash=sha256:f3fd9a1f87f8f0f3f8e9f9bd0195f7516562d13f5b178db8c5784d1f60b60bed \ + --hash=sha256:f47b0815af3948ec6a440b3afa02f05b18cc0939549e91b5c677b5d9c2c8472a \ + --hash=sha256:f991247276797d0c61ab7770bc9791eadc16f683b4d83517f624932adc1a8bab \ + --hash=sha256:ffad64ce7ad3703d652a3fd9af26238377d24ce52c6ad8ff35d26d82f61f493f + # via + # iii-helpers + # iii-sdk diff --git a/browser/scripts/differential_http.py b/browser/scripts/differential_http.py new file mode 100644 index 000000000..1e545dd8a --- /dev/null +++ b/browser/scripts/differential_http.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +"""Hermetic public-wrapper differential checks for the HTTP compatibility tier.""" + +from __future__ import annotations + +import argparse +import asyncio +import gzip +import html +import json +import subprocess +import sys +import threading +import zlib +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +ROOT = Path(__file__).resolve().parent.parent +STANDALONE = ROOT.parent / "scrapling" + + +class Server(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self) -> None: + super().__init__(("127.0.0.1", 0), Handler) + self.attempts: dict[str, int] = {} + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *_: object) -> None: + pass + + def do_GET(self) -> None: + self.respond() + + def do_POST(self) -> None: + self.respond() + + def do_PUT(self) -> None: + self.respond() + + def do_DELETE(self) -> None: + self.respond() + + def respond(self) -> None: + parsed = urlsplit(self.path) + if parsed.path == "/chain": + remaining = int(parsed.query or "0") + if remaining: + return self.send(302, b"chain", [("Location", f"/chain?{remaining - 1}")]) + if parsed.path == "/redirect": + return self.send(302, b"redirect", [("Location", "/echo?redirected=1")]) + if parsed.path == "/loop": + return self.send(302, b"loop", [("Location", "/loop")]) + if parsed.path == "/flaky": + attempts = self.server.attempts # type: ignore[attr-defined] + attempts[parsed.query] = attempts.get(parsed.query, 0) + 1 + if attempts[parsed.query] == 1: + self.connection.shutdown(2) + self.connection.close() + return + if parsed.path == "/latin": + return self.send( + 200, + b"

caf\xe9

", + [("Content-Type", "text/html; charset=iso-8859-1")], + ) + if parsed.path == "/gzip": + return self.send( + 200, + gzip.compress(b"

compressed

", mtime=0), + [("Content-Type", "text/html"), ("Content-Encoding", "gzip")], + ) + if parsed.path == "/deflate": + return self.send( + 200, + zlib.compress(b"

compressed

"), + [("Content-Type", "text/html"), ("Content-Encoding", "deflate")], + ) + if parsed.path == "/duplicates": + return self.send( + 201, + b"

duplicates

", + [ + ("Content-Type", "text/html"), + ("X-Test", "one"), + ("X-Test", "two"), + ("Set-Cookie", "first=1; Path=/"), + ("Set-Cookie", "second=2; Path=/"), + ], + ) + if parsed.path == "/set-cookie": + return self.send( + 200, + b"

stored

", + [("Content-Type", "text/html"), ("Set-Cookie", "sid=abc; Path=/")], + ) + length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(length).decode("utf-8", "replace") + selected = [ + [name.lower(), value] + for name, value in self.headers.items() + if name.lower() + in {"authorization", "content-type", "cookie", "proxy-authorization", "x-first", "x-second"} + ] + echoed = json.dumps( + {"method": self.command, "target": self.path, "body": body, "headers": selected}, + ensure_ascii=False, + separators=(",", ":"), + ) + self.send(200, f"
{html.escape(echoed)}
".encode()) + + def send(self, status: int, body: bytes, headers: list[tuple[str, str]] | None = None) -> None: + self.send_response_only(status) + for name, value in headers or [("Content-Type", "text/html; charset=utf-8")]: + self.send_header(name, value) + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + + +class Oracle: + def __init__(self) -> None: + sys.path.insert(0, str(STANDALONE)) + from src import sessions + from src.handlers import create_handlers + + self.sessions = sessions.setup(max_sessions=8, idle_timeout=900) + self.handlers = create_handlers(lambda: {}) + self.loop = asyncio.new_event_loop() + + def query(self, function: str, payload: dict[str, Any]) -> dict[str, Any]: + name = function.removeprefix("browser::").replace("-", "_") + try: + return {"ok": self.loop.run_until_complete(self.handlers[name](payload))} + except Exception as error: # noqa: BLE001 - exact error text is contract data + return {"err": str(error)} + + def close(self) -> None: + self.sessions.close_all() + self.loop.close() + + +class Driver: + def __init__(self) -> None: + subprocess.run( + ["cargo", "build", "--quiet", "--example", "scrapling_http_differential", "--features", "scrapling-compat"], + cwd=ROOT, + check=True, + ) + metadata = json.loads( + subprocess.check_output(["cargo", "metadata", "--format-version=1", "--no-deps"], cwd=ROOT) + ) + executable = Path(metadata["target_directory"]) / "debug/examples/scrapling_http_differential" + self.process = subprocess.Popen([executable], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) + + def query(self, function: str, payload: dict[str, Any]) -> dict[str, Any]: + assert self.process.stdin and self.process.stdout + json.dump({"function": function, "payload": payload}, self.process.stdin, separators=(",", ":")) + self.process.stdin.write("\n") + self.process.stdin.flush() + line = self.process.stdout.readline() + if not line: + raise RuntimeError("Rust HTTP differential driver stopped") + return json.loads(line) + + def close(self) -> None: + self.process.terminate() + self.process.wait() + + +def normalized(value: Any, origin: str) -> Any: + if isinstance(value, str): + return value.replace(origin, "{origin}") + if isinstance(value, list): + return [normalized(item, origin) for item in value] + if isinstance(value, dict): + return {key: normalized(item, origin) for key, item in value.items()} + return value + + +def cases(origin: str) -> list[tuple[str, dict[str, Any]]]: + def request(path: str, **values: Any) -> dict[str, Any]: + return { + "url": origin + path, + "impersonate": "", + "stealthy_headers": False, + "retries": 1, + "include_html": True, + **values, + } + + return [ + ("get", request("/echo")), + ("default-impersonation", {"url": origin + "/echo", "retries": 1, "include_html": True}), + ("params", request("/echo?old=1", params={"old": "2", "many": [1, 2], "flag": True, "none": None})), + ("post-form", request("/echo", method="post", data={"a": 1, "flag": True, "none": None})), + ("post-json", request("/echo", method="post", json={"a": [1, True, None]})), + ("put", request("/echo", method="put", data={"a": "b"})), + ("delete", request("/echo", method="delete", json={"delete": True})), + ("headers", request("/echo", headers={"x-second": "2", "x-first": "1", "x-empty": ""})), + ("cookies", request("/echo", cookies={"second": "2", "first": "1"})), + ("auth", request("/echo", auth=["user", "pass"])), + ("redirect", request("/redirect")), + ("redirect-all", request("/redirect", follow_redirects=True)), + ("no-redirect", request("/redirect", follow_redirects=False)), + ("duplicates", request("/duplicates")), + ("latin", request("/latin")), + ("gzip", request("/gzip")), + ("deflate", request("/deflate")), + ("retry", request("/flaky?one", retries=2, retry_delay=0)), + ("bulk", {**request("/unused"), "url": None, "urls": [origin + "/echo?a", origin + "/latin"]}), + ("missing-url", {"retries": 1}), + ("bad-method", request("/echo", method="patch")), + ("bad-impersonation", request("/echo", impersonate="bogus")), + ("proxy-conflict", request("/echo", proxy="http://one", proxies={"all": "http://two"})), + ("bad-auth", request("/echo", auth=["one"])), + ("negative-timeout", request("/echo", timeout=-1)), + ("negative-redirects", request("/echo", max_redirects=-2)), + ("negative-retries", request("/echo", retries=-1)), + ("negative-delay", request("/flaky?delay", retries=2, retry_delay=-1)), + ("redirect-limit", request("/loop", max_redirects=1)), + ("unlimited-redirects", request("/chain?35", follow_redirects=True, max_redirects=-1)), + ( + "proxy", + {**request("/unused"), "url": "http://example.invalid/echo", "proxy": origin}, + ), + ( + "proxies-scheme", + {**request("/unused"), "url": "http://example.invalid/echo", "proxies": {"http": origin}}, + ), + ( + "proxies-host", + { + **request("/unused"), + "url": "http://example.invalid/echo", + "proxies": {"http://example.invalid": origin}, + }, + ), + ( + "proxy-auth", + { + **request("/unused"), + "url": "http://example.invalid/echo", + "proxy": origin, + "proxy_auth": ["proxy-user", "proxy-pass"], + }, + ), + ] + + +def session_checks(oracle: Oracle, driver: Driver, origin: str) -> list[tuple[str, Any, Any, Any]]: + mismatches = [] + constructor = {"type": "http", "impersonate": "", "headers": {"x-first": "session"}} + expected_open = oracle.query("browser::session-open", constructor) + actual_open = driver.query("browser::session-open", constructor) + expected_id = expected_open.get("ok", {}).get("session_id") + actual_id = actual_open.get("ok", {}).get("session_id") + for label, session_id in [("oracle", expected_id), ("rust", actual_id)]: + valid = ( + isinstance(session_id, str) + and len(session_id) == 32 + and all(character in "0123456789abcdef" for character in session_id) + and session_id[12] == "4" + and session_id[16] in "89ab" + ) + if not valid: + mismatches.append((f"session-open-{label}-uuid", constructor, "UUID4 hex", session_id)) + if not isinstance(expected_id, str) or not isinstance(actual_id, str): + return mismatches + expected_open["ok"]["session_id"] = "{session}" + actual_open["ok"]["session_id"] = "{session}" + if expected_open != actual_open: + mismatches.append(("session-open", constructor, expected_open, actual_open)) + + for name, path in [("session-set-cookie", "/set-cookie"), ("session-cookie-state", "/echo")]: + common = {"url": origin + path, "include_html": True} + expected = normalized( + oracle.query("browser::session-fetch", {"session_id": expected_id, **common}), origin + ) + actual = normalized( + driver.query("browser::session-fetch", {"session_id": actual_id, **common}), origin + ) + if expected != actual: + mismatches.append((name, common, expected, actual)) + + expected_list = oracle.query("browser::session-list", {}) + actual_list = driver.query("browser::session-list", {}) + for value, session_id in [(expected_list, expected_id), (actual_list, actual_id)]: + for item in value.get("ok", {}).get("sessions", []): + if item.get("session_id") == session_id: + item["session_id"] = "{session}" + created_at = item.get("created_at") + last_used = item.get("last_used") + if isinstance(created_at, int | float) and isinstance(last_used, int | float): + if last_used < created_at: + mismatches.append( + ("session-list-time-order", {}, "last_used >= created_at", item.copy()) + ) + for key in ("created_at", "last_used", "idle_s"): + number = item.get(key) + if not isinstance(number, int | float) or number < 0: + mismatches.append((f"session-list-{key}", {}, "non-negative number", number)) + item[key] = "{number}" + if expected_list != actual_list: + mismatches.append(("session-list", {}, expected_list, actual_list)) + + for name in ("session-close", "session-close-again"): + expected = oracle.query("browser::session-close", {"session_id": expected_id}) + actual = driver.query("browser::session-close", {"session_id": actual_id}) + if expected != actual: + mismatches.append((name, {}, expected, actual)) + return mismatches + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--oracle-check", choices=["full", "parser-runtime", "none"], default="full") + args = parser.parse_args() + if sys.version_info[:3] != (3, 12, 13): + parser.error(f"requires frozen CPython 3.12.13, got {sys.version.split()[0]}") + if args.oracle_check != "none": + command = [sys.executable, ROOT / "scripts/verify_oracle.py"] + if args.oracle_check == "parser-runtime": + command.append("--parser-runtime") + subprocess.run(command, check=True) + + server = Server() + origin = f"http://127.0.0.1:{server.server_port}" + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + oracle, driver = Oracle(), Driver() + mismatches = [] + try: + for name, payload in cases(origin): + server.attempts.clear() + expected = normalized(oracle.query("browser::fetch", payload), origin) + server.attempts.clear() + actual = normalized(driver.query("browser::fetch", payload), origin) + if expected != actual: + mismatches.append((name, payload, expected, actual)) + mismatches.extend(session_checks(oracle, driver, origin)) + finally: + oracle.close() + driver.close() + server.shutdown() + server.server_close() + + for name, payload, expected, actual in mismatches: + print(f"{name}: payload={json.dumps(normalized(payload, origin), ensure_ascii=False)}") + print(f" expected={json.dumps(expected, ensure_ascii=False)}") + print(f" actual={json.dumps(actual, ensure_ascii=False)}") + if mismatches: + print(f"FAILED: {len(mismatches)} HTTP/session mismatches", file=sys.stderr) + return 1 + print(f"PASS HTTP: {len(cases(origin))} request cases plus persistent session state") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/browser/scripts/differential_parser.py b/browser/scripts/differential_parser.py new file mode 100644 index 000000000..056c6b091 --- /dev/null +++ b/browser/scripts/differential_parser.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Deterministic public-wrapper differential corpus for HTML, CSS, and XPath.""" + +from __future__ import annotations + +import argparse +import asyncio +import copy +import json +import random +import subprocess +import sys +from pathlib import Path +from typing import Any, Iterable + +SEED = 0x31213 +ROOT = Path(__file__).resolve().parent.parent +STANDALONE = ROOT.parent / "scrapling" + +HTML_TAGS = ["div", "span", "p", "a", "b", "i", "table", "tr", "td", "template", "svg", "math"] +HTML_ATTRS = ["", " id=x", " class='a b'", " disabled", " data-x=", " a=1 a=2", " checked=''", " title='&'"] +HTML_TEXT = ["", "a", " b ", "&", "�", "€", "éΩ中", "", ""] + +CSS_HTML = """ +
+

one bold

+S
""" + +CSS_ATOMS = ["*", "li", "p", "a", "#root", ".item", "li.a", "[hidden]", "[data-n='2']", "[lang|='en']", "[class~='a']"] +CSS_PSEUDOS = ["", ":first-child", ":last-child", ":nth-child(2)", ":nth-of-type(2)", ":not(.b)", ":is(.a,.b)"] +CSS_ERRORS = ["[", "li:", "li::", "li:nth-child(", "li:not(", "li > > a", "#", ".", "li,", "::attr("] + +XPATH_STEPS = ["*", "li", "p", "a", "span", "text()", "@class", "@data-n"] +XPATH_AXES = ["child", "descendant", "ancestor", "following-sibling", "preceding-sibling", "following", "preceding"] +XPATH_ERRORS = ["//[", "///", "//*[(]", "//li[", "unknown()", "//li/unknown::x", "//li[@", "(", "//li |"] + + +def html_cases(rng: random.Random, count: int) -> Iterable[tuple[str, dict[str, Any]]]: + for _ in range(count): + tokens: list[str] = [] + opened: list[str] = [] + for _ in range(rng.randint(3, 12)): + action = rng.randrange(5) + if action <= 1: + tag = rng.choice(HTML_TAGS) + tokens.append(f"<{tag}{rng.choice(HTML_ATTRS)}>") + opened.append(tag) + elif action == 2 and opened: + tag = opened.pop(rng.randrange(len(opened))) + tokens.append(f"") + elif action == 3: + tokens.append(rng.choice(HTML_TEXT)) + else: + tag = rng.choice(HTML_TAGS) + tokens.append(f"<{tag}{rng.choice(HTML_ATTRS)}/>") + if rng.getrandbits(1): + tokens.extend(f"" for tag in reversed(opened[: rng.randint(0, len(opened))])) + yield "browser::extract", { + "html": "".join(tokens), + "selectors": [ + {"name": "elements", "css": "*", "html": True, "all": True}, + {"name": "text", "xpath": "//text()", "all": True}, + {"name": "attrs", "xpath": "//@*", "all": True}, + ], + } + + +def css_cases(rng: random.Random, count: int) -> Iterable[tuple[str, dict[str, Any]]]: + for index in range(count): + if index % 5 == 0: + query = rng.choice(CSS_ERRORS) + else: + left = rng.choice(CSS_ATOMS) + rng.choice(CSS_PSEUDOS) + if rng.getrandbits(1): + right = rng.choice(CSS_ATOMS) + rng.choice(CSS_PSEUDOS) + query = left + rng.choice([" ", " > ", " + ", " ~ ", ", "]) + right + else: + query = left + if rng.randrange(4) == 0: + query += rng.choice(["::text", "::attr(class)", "::attr(href)"]) + payload: dict[str, Any] = {"html": CSS_HTML, "query": query, "first": bool(rng.getrandbits(1))} + if rng.randrange(4) == 0: + payload["attr"] = rng.choice(["class", "href", "missing"]) + yield "browser::css", payload + + +def xpath_cases(rng: random.Random, count: int) -> Iterable[tuple[str, dict[str, Any]]]: + predicates = ["", "[1]", "[last()]", "[position()=2]", "[@class]", "[contains(@class,'a')]", "[string-length(.)>0]"] + scalars = ["count(//li)", "string(//p[1])", "boolean(//a)", "false()", "true()", "1 + 2", "round(2.5)"] + for index in range(count): + choice = index % 10 + if choice == 0: + query = rng.choice(XPATH_ERRORS) + elif choice == 1: + query = rng.choice(scalars) + else: + step = rng.choice(XPATH_STEPS) + query = "//" + step + rng.choice(predicates) + if rng.getrandbits(1): + query += "/" + rng.choice(XPATH_AXES) + "::" + rng.choice(XPATH_STEPS[:5]) + rng.choice(predicates[:4]) + if rng.randrange(5) == 0: + query = f"({query})[{rng.choice(['1', 'last()', 'position()=2'])}]" + if rng.randrange(7) == 0: + query += " | //p[1]" + payload = {"html": CSS_HTML, "query": query, "first": bool(rng.getrandbits(1))} + if rng.randrange(5) == 0: + payload["attr"] = rng.choice(["class", "href", "missing"]) + yield "browser::xpath", payload + + +class Oracle: + def __init__(self) -> None: + sys.path.insert(0, str(STANDALONE)) + from src.handlers import create_handlers + + self.handlers = create_handlers(lambda: {}) + self.loop = asyncio.new_event_loop() + + def query(self, function: str, payload: dict[str, Any]) -> dict[str, Any]: + name = function.removeprefix("browser::").replace("-", "_") + try: + return {"ok": self.loop.run_until_complete(self.handlers[name](payload))} + except Exception as error: # noqa: BLE001 - error text is part of the contract + return {"err": str(error)} + + def close(self) -> None: + self.loop.close() + + +class Driver: + def __init__(self) -> None: + subprocess.run(["cargo", "build", "--quiet", "--example", "scrapling_differential"], cwd=ROOT, check=True) + metadata = json.loads(subprocess.check_output(["cargo", "metadata", "--format-version=1", "--no-deps"], cwd=ROOT)) + executable = Path(metadata["target_directory"]) / "debug/examples/scrapling_differential" + self.process = subprocess.Popen([executable], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) + + def query(self, function: str, payload: dict[str, Any]) -> dict[str, Any]: + assert self.process.stdin and self.process.stdout + json.dump({"function": function, "payload": payload}, self.process.stdin, ensure_ascii=False, separators=(",", ":")) + self.process.stdin.write("\n") + self.process.stdin.flush() + line = self.process.stdout.readline() + if not line: + raise RuntimeError("Rust differential driver stopped") + return json.loads(line) + + def close(self) -> None: + self.process.terminate() + self.process.wait() + + +def minimize(function: str, payload: dict[str, Any], oracle: Oracle, driver: Driver) -> dict[str, Any]: + candidate = copy.deepcopy(payload) + original_expected = oracle.query(function, payload) + original_actual = driver.query(function, payload) + outcome = (next(iter(original_expected)), next(iter(original_actual))) + fields = ["html", "query"] + changed = True + while changed: + changed = False + for field in fields: + value = candidate.get(field) + if not isinstance(value, str): + continue + for index in range(len(value)): + smaller = copy.deepcopy(candidate) + smaller[field] = value[:index] + value[index + 1 :] + expected, actual = oracle.query(function, smaller), driver.query(function, smaller) + if ( + expected != actual + and (next(iter(expected)), next(iter(actual))) == outcome + ): + candidate = smaller + changed = True + break + if changed: + break + return candidate + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--grammar", choices=["all", "html", "css", "xpath"], default="all") + parser.add_argument("--cases", type=int, default=10_000, help="cases per selected grammar") + parser.add_argument("--max-mismatches", type=int, default=20) + parser.add_argument( + "--oracle-check", + choices=["full", "parser-runtime", "none"], + default="full", + ) + args = parser.parse_args() + if sys.version_info[:3] != (3, 12, 13): + parser.error(f"requires frozen CPython 3.12.13, got {sys.version.split()[0]}") + if args.oracle_check != "none": + command = [sys.executable, ROOT / "scripts/verify_oracle.py"] + if args.oracle_check == "parser-runtime": + command.append("--parser-runtime") + subprocess.run(command, check=True) + + generators = {"html": html_cases, "css": css_cases, "xpath": xpath_cases} + selected = generators if args.grammar == "all" else {args.grammar: generators[args.grammar]} + oracle, driver = Oracle(), Driver() + mismatches: list[ + tuple[str, int, dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]] + ] = [] + try: + for offset, (grammar, generate) in enumerate(selected.items()): + rng = random.Random(SEED + offset) + for index, (function, payload) in enumerate(generate(rng, args.cases), 1): + expected, actual = oracle.query(function, payload), driver.query(function, payload) + if expected != actual: + reduced = minimize(function, payload, oracle, driver) + mismatches.append((grammar, index, payload, expected, actual, reduced, oracle.query(function, reduced))) + if len(mismatches) >= args.max_mismatches: + break + if len(mismatches) >= args.max_mismatches: + break + finally: + oracle.close() + driver.close() + + for grammar, index, payload, expected, actual, reduced, reduced_expected in mismatches: + print(f"{grammar} case={index} payload={json.dumps(reduced, ensure_ascii=False)}") + print(f" expected={json.dumps(reduced_expected, ensure_ascii=False)}") + print(f" original_payload={json.dumps(payload, ensure_ascii=False)}") + print(f" original_expected={json.dumps(expected, ensure_ascii=False)}") + print(f" original_actual={json.dumps(actual, ensure_ascii=False)}") + if mismatches: + print(f"FAILED: {len(mismatches)} mismatches", file=sys.stderr) + return 1 + for grammar in selected: + print(f"PASS {grammar}: {args.cases} cases (seed={SEED + list(selected).index(grammar):#x})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/browser/scripts/fetch_chromium_artifacts.sh b/browser/scripts/fetch_chromium_artifacts.sh new file mode 100755 index 000000000..882555a04 --- /dev/null +++ b/browser/scripts/fetch_chromium_artifacts.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +worker_dir=$(cd "$script_dir/.." && pwd) +manifest="$worker_dir/oracle/manifest.json" +cache_dir=${SCRAPLING_CHROMIUM_ARTIFACT_DIR:-"$worker_dir/target/scrapling-chromium"} +mode=${1:-fetch} +target=${2:-} + +if [[ -z $target ]]; then + case "$(uname -m)-$(uname -s)" in + x86_64-Linux) target=x86_64-unknown-linux-gnu ;; + aarch64-Linux) target=aarch64-unknown-linux-gnu ;; + *) echo "unsupported host; pass a Tier-1 Linux target" >&2; exit 2 ;; + esac +fi +case "$mode:$target" in + fetch:x86_64-unknown-linux-gnu|verify:x86_64-unknown-linux-gnu) suffix=linux-x64 ;; + fetch:aarch64-unknown-linux-gnu|verify:aarch64-unknown-linux-gnu) suffix=linux-arm64 ;; + *) echo "usage: $0 [fetch|verify] [x86_64-unknown-linux-gnu|aarch64-unknown-linux-gnu]" >&2; exit 2 ;; +esac + +target_dir="$cache_dir/$target" +mkdir -p "$target_dir" + +while IFS='|' read -r name archive bytes sha256 url; do + archive_path="$target_dir/$archive" + if [[ $mode == fetch ]]; then + temporary=$(mktemp "$target_dir/.download.XXXXXX") + curl --fail --location --retry 3 --output "$temporary" "$url" + mv "$temporary" "$archive_path" + fi + [[ -f $archive_path ]] || { echo "missing artifact: $archive_path" >&2; exit 1; } + actual_bytes=$(wc -c < "$archive_path") + [[ $actual_bytes == "$bytes" ]] || { + echo "size mismatch for $archive_path: expected $bytes, got $actual_bytes" >&2 + exit 1 + } + actual_sha=$(sha256sum "$archive_path" | awk '{print $1}') + [[ $actual_sha == "$sha256" ]] || { + echo "SHA-256 mismatch for $archive_path: expected $sha256, got $actual_sha" >&2 + exit 1 + } + if [[ $mode == fetch ]]; then + unzip -oq "$archive_path" -d "$target_dir" + fi + echo "verified $name $sha256" +done < <( + python3 - "$manifest" "$suffix" <<'PY' +import json, pathlib, sys +manifest = json.loads(pathlib.Path(sys.argv[1]).read_text()) +suffix = sys.argv[2] +for item in manifest["browser"]["archives"]: + if item["name"] in {f"chromium-{suffix}", f"chromium-headless-shell-{suffix}"}: + print("|".join(map(str, (item["name"], item["path"], item["size"], item["sha256"], item["url"])))) +PY +) + +if [[ $mode == fetch ]]; then + chrome_dir=$(find "$target_dir" -mindepth 1 -maxdepth 1 -type d -name 'chrome-linux*' ! -name 'chrome-headless*' | head -1) + headless_dir=$(find "$target_dir" -mindepth 1 -maxdepth 1 -type d -name 'chrome-headless-shell-linux*' | head -1) + mkdir -p "$target_dir/pw/chromium-1223" "$target_dir/pw/chromium_headless_shell-1223" + ln -sfn "$chrome_dir" "$target_dir/pw/chromium-1223/$(basename "$chrome_dir")" + ln -sfn "$headless_dir" "$target_dir/pw/chromium_headless_shell-1223/$(basename "$headless_dir")" +fi + +if [[ $target == x86_64-unknown-linux-gnu ]]; then + executable="$target_dir/chrome-linux64/chrome" +else + executable="$target_dir/chrome-linux/chrome" +fi +[[ -x $executable ]] || { + echo "verified archive is not extracted; run '$0 fetch $target'" >&2 + exit 1 +} +"$executable" --version +printf 'SCRAPLING_CHROMIUM_EXECUTABLE=%s\n' "$executable" +printf 'PLAYWRIGHT_BROWSERS_PATH=%s\n' "$target_dir/pw" diff --git a/browser/scripts/fetch_curl_impersonate_artifacts.sh b/browser/scripts/fetch_curl_impersonate_artifacts.sh new file mode 100755 index 000000000..68e94e88f --- /dev/null +++ b/browser/scripts/fetch_curl_impersonate_artifacts.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +crate_dir=$(cd "$script_dir/../vendor/curl_impersonate_sys" && pwd) +manifest="$crate_dir/artifacts.manifest" +cache_dir=${CURL_IMPERSONATE_ARTIFACT_DIR:-"$crate_dir/artifacts"} +mode=fetch +requested_target=${1:-} + +if [[ $requested_target == --verify ]]; then + mode=verify + requested_target=${2:-} +fi + +if [[ -z $requested_target ]]; then + case "$(uname -m)-$(uname -s)" in + x86_64-Linux) requested_target=x86_64-unknown-linux-gnu ;; + aarch64-Linux) requested_target=aarch64-unknown-linux-gnu ;; + *) + echo "unsupported host; pass x86_64-unknown-linux-gnu or aarch64-unknown-linux-gnu" >&2 + exit 2 + ;; + esac +fi + +verify_one() { + local target=$1 archive=$2 bytes=$3 expected=$4 + local target_dir="$cache_dir/$target" + local archive_path="$target_dir/$archive" + local root="$target_dir/root" + + [[ -f $archive_path ]] || { + echo "missing artifact: $archive_path" >&2 + return 1 + } + local actual_bytes + actual_bytes=$(wc -c < "$archive_path") + [[ $actual_bytes == "$bytes" ]] || { + echo "size mismatch for $archive_path: expected $bytes, got $actual_bytes" >&2 + return 1 + } + local actual + actual=$(sha256sum "$archive_path" | awk '{print $1}') + [[ $actual == "$expected" ]] || { + echo "SHA-256 mismatch for $archive_path: expected $expected, got $actual" >&2 + return 1 + } + [[ -f "$root/libcurl-impersonate.a" && -f "$root/include/curl/curl.h" ]] || { + echo "verified archive is not extracted under $root" >&2 + return 1 + } + echo "verified $target $expected" +} + +fetch_one() { + local target=$1 archive=$2 bytes=$3 expected=$4 url=$5 + local target_dir="$cache_dir/$target" + mkdir -p "$target_dir/root" + local archive_path="$target_dir/$archive" + local temporary + temporary=$(mktemp "$target_dir/.download.XXXXXX") + curl --fail --location --retry 3 --output "$temporary" "$url" + + local actual_bytes + actual_bytes=$(wc -c < "$temporary") + [[ $actual_bytes == "$bytes" ]] || { + echo "size mismatch for downloaded $url: expected $bytes, got $actual_bytes" >&2 + return 1 + } + local actual + actual=$(sha256sum "$temporary" | awk '{print $1}') + [[ $actual == "$expected" ]] || { + echo "SHA-256 mismatch for downloaded $url: expected $expected, got $actual" >&2 + return 1 + } + + mv "$temporary" "$archive_path" + tar -xzf "$archive_path" -C "$target_dir/root" + verify_one "$target" "$archive" "$bytes" "$expected" +} + +matched=0 +while IFS='|' read -r target archive bytes sha256 url; do + [[ -z $target || $target == \#* ]] && continue + if [[ $requested_target != all && $requested_target != "$target" ]]; then + continue + fi + matched=1 + if [[ $mode == verify ]]; then + verify_one "$target" "$archive" "$bytes" "$sha256" + else + fetch_one "$target" "$archive" "$bytes" "$sha256" "$url" + fi +done < "$manifest" + +if [[ $matched == 0 ]]; then + echo "target not present in $manifest: $requested_target" >&2 + exit 2 +fi + diff --git a/browser/scripts/gen_goldens.py b/browser/scripts/gen_goldens.py new file mode 100644 index 000000000..b7c27678d --- /dev/null +++ b/browser/scripts/gen_goldens.py @@ -0,0 +1,649 @@ +#!/usr/bin/env python3 +"""Golden generator for the browser worker's scrapling surface. +THE ONLY WRITER of tests/golden/**. + +Run with the locked oracle environment (see oracle/README.md): + + .oracle/bin/python scripts/gen_goldens.py schemas + .oracle/bin/python scripts/gen_goldens.py behavior + +Ground truth is the Python worker source in ../scrapling (schemas.py, core.py). + +Every id in this file is the PYTHON id (`scrapling::css`) because that is what +addresses the reference implementation. The browser worker namespaces its own +surface as `browser::css`, so `wire_id()` is applied at write time +only — to the emitted `function_id`/`function` fields and golden filenames. +""" + +import argparse +import asyncio +import base64 +import hashlib +import http.server +import json +import os +import random +import socketserver +import subprocess +import tempfile +import threading +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent # browser/scripts +WORKER = HERE.parent # browser/ +REPO = WORKER.parent # workers repo root +sys.path.insert(0, str(REPO / "scrapling")) # `src` package of the Python worker + + +def wire_ids_in(value): + """Rewrite `scrapling::` -> `browser::` in every string, recursively. + + wire_id() only fixes the function_id field; schema/description free text + (e.g. crawl's "default scrapling::crawl" stream hint) names the worker's + own ids too, and those must show the browser:: surface the worker actually + serves, not the Python reference's. + """ + if isinstance(value, str): + return value.replace("scrapling::", "browser::") + if isinstance(value, dict): + return {key: wire_ids_in(inner) for key, inner in value.items()} + if isinstance(value, list): + return [wire_ids_in(inner) for inner in value] + return value + + +def wire_id(python_id: str) -> str: + """Python's `scrapling::` -> this worker's root `browser::`.""" + leaf = python_id.removeprefix("scrapling::") + if leaf == "screenshot": + leaf = "screenshot-url" + return "browser::" + leaf + + +# In `schemas.py`'s own FUNCTIONS order — the Rust catalog() must match it. +ALL_IDS = [ + "scrapling::fetch", + "scrapling::stealthy-fetch", + "scrapling::dynamic-fetch", + "scrapling::screenshot", + "scrapling::extract", + "scrapling::css", + "scrapling::xpath", + "scrapling::regex", + "scrapling::find-similar", + "scrapling::find", + "scrapling::find-by-text", + "scrapling::find-by-regex", + "scrapling::describe", + "scrapling::to-markdown", + "scrapling::session-open", + "scrapling::session-fetch", + "scrapling::session-close", + "scrapling::session-list", + "scrapling::crawl", +] + +def dump(path: Path, obj) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(obj, indent=2, ensure_ascii=False) + "\n") + + +def gen_schemas(output: Path = WORKER) -> None: + from src.schemas import FUNCTIONS # imports only typing — safe + + by_id = {f["id"]: f for f in FUNCTIONS} + missing = [fid for fid in ALL_IDS if fid not in by_id] + if missing: # schemas.py drifted — fail loudly, don't emit half a surface + raise SystemExit(f"ids in ALL_IDS but not in schemas.py FUNCTIONS: {missing}") + for fid in ALL_IDS: + spec = by_id[fid] + dump( + output / "tests/golden/schemas" / (wire_id(fid).replace("::", ".") + ".json"), + wire_ids_in( + { + "function_id": wire_id(spec["id"]), + "description": spec["description"], + "request_schema": spec["request"], + "response_schema": spec["response"], + } + ), + ) + print(f"wrote {len(ALL_IDS)} schema goldens") + + +# ---- behavior fixtures (Task 6 fills CORPUS/MATRIX; later tasks append) ---- + +CORPUS: dict[str, str] = {} # name -> html (loaded from tests/corpus/) +MATRIX: list[dict] = [ # {function, case, request:{...,"html": ""}} + {"function": "scrapling::regex", "case": "smoke_all", + "request": {"html": "basic", "pattern": r"\d+"}}, + {"function": "scrapling::regex", "case": "first_group", + "request": {"html": "basic", "pattern": r"price (\d+)", "first": True}}, + {"function": "scrapling::regex", "case": "no_match_first", + "request": {"html": "basic", "pattern": r"zzz", "first": True}}, + {"function": "scrapling::regex", "case": "no_match_all", + "request": {"html": "basic", "pattern": r"zzz"}}, + {"function": "scrapling::regex", "case": "across_fragments_edge", + "request": {"html": "edge", "pattern": r"CONDITION: \w+"}}, + {"function": "scrapling::regex", "case": "entities_edge", + "request": {"html": "edge", "pattern": r"A&B"}}, + {"function": "scrapling::regex", "case": "two_groups", + "request": {"html": "basic", "pattern": r"(\w+) (\d+)"}}, + {"function": "scrapling::regex", "case": "named_backreference", + "request": {"html": "

ab-ab ab-ac

", "pattern": r"(?Pab)-(?P=word)"}}, + {"function": "scrapling::regex", "case": "lookbehind_and_conditional", + "request": {"html": "

abcdef ab c

", "pattern": r"(?:(?<=abc)def)|((a)?(?(2)b|c))"}}, + {"function": "scrapling::regex", "case": "atomic_and_possessive", + "request": {"html": "

aaab aaaa

", "pattern": r"(?>a+)b|a++a"}}, + {"function": "scrapling::regex", "case": "unicode_name_escape", + "request": {"html": "

A—B

", "pattern": r"\N{EM DASH}"}}, + {"function": "scrapling::find-by-regex", "case": "unicode_ignorecase_extra", + "request": {"html": "

Kelvin

", "pattern": r"kelvin"}}, + {"function": "scrapling::regex", "case": "zero_width_findall", + "request": {"html": "

ab

", "pattern": r"x*"}}, + {"function": "scrapling::regex", "case": "w3lib_html4_entities", + "request": {"html": "

&apos; &Copy; &#128; &#129; &unknown; &unknown

", + "pattern": r"&(?:[A-Za-z]+|#[0-9]+);?"}}, + {"function": "scrapling::regex", "case": "invalid_unterminated_group", + "request": {"html": "basic", "pattern": "("}}, + {"function": "scrapling::regex", "case": "invalid_range", + "request": {"html": "basic", "pattern": "[z-a]"}}, + {"function": "scrapling::regex", "case": "invalid_global_flag_position", + "request": {"html": "basic", "pattern": "a(?i)b"}}, + {"function": "scrapling::regex", "case": "invalid_variable_lookbehind", + "request": {"html": "basic", "pattern": r"(?<=a*)b"}}, + {"function": "scrapling::regex", "case": "invalid_group_reference", + "request": {"html": "basic", "pattern": r"\1"}}, + {"function": "scrapling::find-by-text", "case": "exact_default", + "request": {"html": "basic", "text": "Apple"}}, + {"function": "scrapling::find-by-text", "case": "partial_case", + "request": {"html": "basic", "text": "aPP", "partial": True}}, + {"function": "scrapling::find-by-text", "case": "case_sensitive_miss", + "request": {"html": "basic", "text": "apple", "case_sensitive": True}}, + {"function": "scrapling::find-by-text", "case": "clean_match_whitespace", + "request": {"html": "edge", "text": "bold"}}, + {"function": "scrapling::find-by-text", "case": "no_clean_exact_ws", + "request": {"html": "edge", "text": "bold", "clean_match": False}}, + {"function": "scrapling::find-by-text", "case": "first_flag", + "request": {"html": "basic", "text": "Apple", "first": True}}, + {"function": "scrapling::find-by-text", "case": "none", + "request": {"html": "basic", "text": "Zebra"}}, + {"function": "scrapling::find-by-regex", "case": "default_insensitive", + "request": {"html": "basic", "pattern": r"price \d+"}}, + {"function": "scrapling::find-by-regex", "case": "sensitive_miss", + "request": {"html": "basic", "pattern": r"PRICE \d+", "case_sensitive": True}}, + {"function": "scrapling::find-by-regex", "case": "limit_zero", + "request": {"html": "messy", "pattern": r"\w+", "limit": 0}}, + {"function": "scrapling::find-by-regex", "case": "messy_cards", + "request": {"html": "messy", "pattern": r"card$"}}, + {"function": "scrapling::find", "case": "by_tag", + "request": {"html": "basic", "tag": "a"}}, + {"function": "scrapling::find", "case": "by_tag_list", + "request": {"html": "basic", "tag": ["h1", "p"]}}, + {"function": "scrapling::find", "case": "attrs_exact_whole_value", + "request": {"html": "messy", "attrs": {"class": "card"}}}, # must NOT match "card wide" + {"function": "scrapling::find", "case": "attrs_bool_coercion", + "request": {"html": "edge", "tag": "input", "attrs": {"disabled": ""}}}, + {"function": "scrapling::find", "case": "tag_and_text_regex", + "request": {"html": "basic", "tag": "a", "text_regex": "Ap", "first": True}}, + {"function": "scrapling::find", "case": "text_regex_only_all_elements", + "request": {"html": "basic", "text_regex": "price"}}, + {"function": "scrapling::find", "case": "no_filters_error", + "request": {"html": "basic"}}, + {"function": "scrapling::find", "case": "limit_clamps", + "request": {"html": "messy", "tag": "li", "limit": 2}}, + {"function": "scrapling::find", "case": "negative_limit_empty", + "request": {"html": "basic", "tag": "a", "limit": -1}}, + {"function": "scrapling::find-by-text", "case": "clean_trims_trailing_space", + "request": {"html": "messy", "text": "intro paragraph with"}}, + {"function": "scrapling::find-by-text", "case": "no_clean_keeps_trailing_space", + "request": {"html": "messy", "text": "intro paragraph with", "clean_match": False}}, + {"function": "scrapling::find", "case": "by_tag_input_attrs_map", + "request": {"html": "edge", "tag": "input"}}, + {"function": "scrapling::find", "case": "attrs_operator_contains", + "request": {"html": "basic", "attrs": {"href*": "/a"}}}, + {"function": "scrapling::find", "case": "attrs_operator_prefix", + "request": {"html": "basic", "attrs": {"href^": "/"}}}, + {"function": "scrapling::find", "case": "tag_html_root", + "request": {"html": "basic", "tag": "html"}}, + {"function": "scrapling::find", "case": "empty_text_regex_error", + "request": {"html": "basic", "text_regex": ""}}, + {"function": "scrapling::find", "case": "star_tag_falls_through", + "request": {"html": "basic", "tag": "*"}}, + {"function": "scrapling::css", "case": "all_default", + "request": {"html": "basic", "query": "li a"}}, + {"function": "scrapling::css", "case": "first_text", + "request": {"html": "basic", "query": "li a", "first": True}}, + {"function": "scrapling::css", "case": "first_attr", + "request": {"html": "basic", "query": "li a", "first": True, "attr": "href"}}, + {"function": "scrapling::css", "case": "attr_miss_in_all", + "request": {"html": "basic", "query": "li a", "attr": "data-x"}}, + {"function": "scrapling::css", "case": "no_match_modes", + "request": {"html": "basic", "query": ".nope"}}, + {"function": "scrapling::css", "case": "no_match_first", + "request": {"html": "basic", "query": ".nope", "first": True}}, + {"function": "scrapling::css", "case": "pseudo_text", + "request": {"html": "basic", "query": "h1::text", "first": True}}, + {"function": "scrapling::css", "case": "pseudo_attr", + "request": {"html": "basic", "query": "a::attr(href)"}}, + {"function": "scrapling::css", "case": "invalid_selector", + "request": {"html": "basic", "query": "li:::bad"}}, + {"function": "scrapling::extract", "case": "mixed_specs", + "request": {"html": "basic", "selectors": [ + {"name": "title", "css": "h1"}, + {"name": "links", "css": "li a", "attr": "href", "all": True}, + {"name": "names", "css": "li a", "all": True}, + {"name": "price", "regex": r"price (\d+)"}, + {"name": "first_li_html", "css": "li", "html": True}, + ]}}, + {"function": "scrapling::extract", "case": "spec_without_query", + "request": {"html": "basic", "selectors": [{"name": "x"}, {"name": "y", "all": True}]}}, + {"function": "scrapling::extract", "case": "empty_selectors", + "request": {"html": "basic", "selectors": []}}, + {"function": "scrapling::extract", "case": "regex_all_spec", + "request": {"html": "basic", "selectors": [{"name": "nums", "regex": r"\d+", "all": True}]}}, + {"function": "scrapling::xpath", "case": "first_h1", + "request": {"html": "basic", "query": "//h1", "first": True}}, + {"function": "scrapling::xpath", "case": "all_anchors_text", + "request": {"html": "basic", "query": "//ul/li/a"}}, + {"function": "scrapling::xpath", "case": "attr_axis_terminal", + "request": {"html": "basic", "query": "//a/@href"}}, + {"function": "scrapling::xpath", "case": "text_terminal", + "request": {"html": "basic", "query": "//h1/text()", "first": True}}, + {"function": "scrapling::xpath", "case": "positional", + "request": {"html": "basic", "query": "//li[2]/a", "first": True}}, + {"function": "scrapling::xpath", "case": "predicate_attr_value", + "request": {"html": "messy", "query": "//div[@class='card']", }}, + {"function": "scrapling::xpath", "case": "contains_href", + "request": {"html": "basic", "query": "//a[contains(@href, 'b')]", "first": True}}, + {"function": "scrapling::xpath", "case": "union_doc_order", + "request": {"html": "basic", "query": "//h1 | //p"}}, + {"function": "scrapling::xpath", "case": "attr_param_on_elements", + "request": {"html": "basic", "query": "//li/a", "attr": "href"}}, + {"function": "scrapling::xpath", "case": "invalid_syntax", + "request": {"html": "basic", "query": "//["}}, + {"function": "scrapling::extract", "case": "xpath_specs", + "request": {"html": "basic", "selectors": [ + {"name": "first_link", "xpath": "//ul/li/a", "attr": "href"}, + {"name": "all_text", "xpath": "//li/a/text()", "all": True}, + ]}}, + # Task 13.5: dom/parser parity batch (blank text, template contents, // + # axis, empty attr). + {"function": "scrapling::xpath", "case": "text_runs_main_messy", + "request": {"html": "messy", "query": "//main/text()"}}, + {"function": "scrapling::xpath", "case": "text_runs_body_messy", + "request": {"html": "messy", "query": "//body/text()"}}, + {"function": "scrapling::css", "case": "text_pseudo_messy_main", + "request": {"html": "messy", "query": "main::text"}}, + {"function": "scrapling::xpath", "case": "template_child_step", + "request": {"html": "messy", "query": "//template/p"}}, + {"function": "scrapling::css", "case": "template_child_css", + "request": {"html": "messy", "query": "template > p", "first": True}}, + {"function": "scrapling::xpath", "case": "explicit_axis_after_slashslash", + "request": {"html": "basic", "query": "//descendant::li[2]"}}, + {"function": "scrapling::css", "case": "empty_attr_falls_back_to_text", + "request": {"html": "basic", "query": "li a", "first": True, "attr": ""}}, + {"function": "scrapling::xpath", "case": "textarea_blank_body_kept", + "request": {"html": "edge", "query": "//textarea/text()"}}, + {"function": "scrapling::describe", "case": "h1_css", + "request": {"html": "basic", "query": "h1"}}, + {"function": "scrapling::describe", "case": "no_match", + "request": {"html": "basic", "query": ".nope"}}, + {"function": "scrapling::describe", "case": "xpath_kind", + "request": {"html": "basic", "query": "//li[2]/a", "kind": "xpath"}}, + {"function": "scrapling::describe", "case": "weird_kind_is_xpath", + "request": {"html": "basic", "query": "//h1", "kind": "bogus"}}, + {"function": "scrapling::describe", "case": "text_pseudo", + "request": {"html": "basic", "query": "h1::text"}}, + {"function": "scrapling::describe", "case": "id_shortcircuit_full", + "request": {"html": "edge", "query": "#wrap p"}}, + # Task 15: find-similar (structural auto-match + __are_alike scoring). + {"function": "scrapling::find-similar", "case": "list_items", + "request": {"html": "basic", "anchor": "li"}}, + {"function": "scrapling::find-similar", "case": "subselectors", + "request": {"html": "basic", "anchor": "li", + "selectors": [{"name": "href", "css": "a", "attr": "href"}]}}, + {"function": "scrapling::find-similar", "case": "anchor_missing", + "request": {"html": "basic", "anchor": ".nope"}}, + {"function": "scrapling::find-similar", "case": "cards_attr_scoring", + "request": {"html": "messy", "anchor": "div.card[data-id='1']"}}, + {"function": "scrapling::find-similar", "case": "cards_high_threshold", + "request": {"html": "messy", "anchor": "div.card[data-id='1']", "similarity_threshold": 0.9}}, + {"function": "scrapling::find-similar", "case": "match_text", + "request": {"html": "basic", "anchor": "li", "match_text": True}}, + # Fix: match_text scoring must use clean_spaces (deletes \n/\r outright, + # never trims), not clean (\n/\r -> ' ', trims). Anchor is attribute-bare + # so __are_alike's attrs contribution is zero either way (target empty, + # candidate non-empty -> "nothing added") — checks come ONLY from + # match_text, so the accept/reject verdict is a pure function of which + # cleaner ran. Anchor's leading text is "line one\nand two " (embedded + # newline); the sibling's is the literal already-squished "line oneand + # two " — clean_spaces(anchor) == that string exactly (ratio 1.0); + # clean(anchor) inserts a space and trims (ratio 0.9677 rounds to 0.97, + # see fix report) — similarity_threshold sits strictly between the two. + {"function": "scrapling::find-similar", "case": "match_text_multiline_leading", + "request": {"html": "edge", "anchor": "#wrap > div", "match_text": True, + "similarity_threshold": 0.99}}, + # Task 16: to-markdown (Convertor._extract_content — main-content + # sanitizer, css_selector scoping, html/markdown/text modes). + {"function": "scrapling::to-markdown", "case": "markdown_basic", + "request": {"html": "basic"}}, + {"function": "scrapling::to-markdown", "case": "text_basic", + "request": {"html": "basic", "format": "text"}}, + {"function": "scrapling::to-markdown", "case": "html_roundtrip", + "request": {"html": "basic", "format": "html"}}, + {"function": "scrapling::to-markdown", "case": "text_messy_main_only", + "request": {"html": "messy", "format": "text", "main_content_only": True}}, + {"function": "scrapling::to-markdown", "case": "scoped_css", + "request": {"html": "messy", "format": "text", "css_selector": "div.card"}}, + {"function": "scrapling::to-markdown", "case": "bad_format", + "request": {"html": "basic", "format": "pdf"}}, + # Fix review: `_HIDDEN_XPATH`/`.iter()` are self-excluding (`.//`) — the + # scope root (body, here) must not drop itself even when IT carries the + # hidden attribute; only matching descendants would. Inline html: no + # corpus entry names this shape. + {"function": "scrapling::to-markdown", "case": "hidden_body_self_exempt", + "request": {"html": '

keep me

', + "format": "text", "main_content_only": True}}, + # markdownify 1.2.3 defaults, after Convertor serializes the selected + # lxml subtree and BeautifulSoup 4.15 reparses it with `html.parser`. + # These are strict wrapper fixtures (not calls to markdownify itself): + # together they cover the tag conversions where htmd differs visibly. + {"function": "scrapling::to-markdown", "case": "markdown_inline_defaults", + "request": {"html": ''' +

A *title*

Sub_head

Third\n head

+

bold em gone + a``b
quote H2O x2

+

https://e.test/a_b + Link + A


+ '''}}, + {"function": "scrapling::to-markdown", "case": "markdown_blocks_and_lists", + "request": {"html": ''' +

one
two

  • A
    • B
      • C
  • D
+
  1. Three
  2. Four

+
Term one
definition\nline
Next
bold
+
\n  a * b\n`tick`\n

after

+ '''}}, + {"function": "scrapling::to-markdown", "case": "markdown_table_and_video", + "request": {"html": ''' +
+
Cap
Head ALT
AB
C
+
Figure text
+ + '''}}, + {"function": "scrapling::to-markdown", "case": "markdown_unknown_and_noise_tags", + "request": {"html": ''' +
alpha beta\n gamma +

before mid after

+
+ '''}}, + # `` makes the second parse observable. lxml first serializes + # the rest of the source as escaped plaintext; BeautifulSoup's + # `html.parser` then treats the serialized closing tags as plaintext too. + {"function": "scrapling::to-markdown", "case": "markdown_html_parser_second_parse", + "request": {"html": "<p>before</p><plaintext><b>looks bold</b><p>tail</p></plaintext><p>after</p>"}}, + # Fix review: a `::text`/`::attr()` pseudo-selector match must render + # through the same pipeline as an element match, not be silently dropped. + {"function": "scrapling::to-markdown", "case": "pseudo_text_selector_text_mode", + "request": {"html": "basic", "format": "text", "css_selector": "li a::text"}}, + {"function": "scrapling::to-markdown", "case": "pseudo_text_selector_html_mode", + "request": {"html": "basic", "format": "html", "css_selector": "li a::text"}}, + # Final-review fix wave: detached `::text` (parsel/scrapy's descendant- + # text idiom, "a ::text" — self + every descendant, any depth — distinct + # from attached "a::text", own runs only) and its bare form. + {"function": "scrapling::css", "case": "detached_text_descendants", + "request": {"html": "basic", "query": "li ::text"}}, + {"function": "scrapling::css", "case": "detached_text_first", + "request": {"html": "basic", "query": "h1 ::text", "first": True}}, + # Genuinely bare `::text` (empty stem, no element name at all) — distinct + # from detached_text_first above, which still has a stem ("h1"). + {"function": "scrapling::css", "case": "bare_detached_text", + "request": {"html": "basic", "query": "::text"}}, + {"function": "scrapling::extract", "case": "detached_text_spec", + "request": {"html": "messy", "selectors": [{"name": "card_text", "css": "div.card ::text", "all": True}]}}, + # Libxml HTML recovery and serialization contract. + {"function": "scrapling::find", "case": "implied_document_nodes", + "request": {"html": "<title>T</title><p>P", "tag": ["html", "head", "body"]}}, + {"function": "scrapling::find", "case": "duplicate_and_boolean_attributes", + "request": {"html": "<input z=1 disabled a='' z=2 checked=checked>", "tag": "input"}}, + {"function": "scrapling::extract", "case": "malformed_table_recovery", + "request": {"html": "<table><td>A<td>B<div>C", "selectors": [{"name": "table", "css": "table", "html": True}]}}, + {"function": "scrapling::extract", "case": "misnested_formatting_recovery", + "request": {"html": "<p><b>one<i>two</b>three</i>tail", "selectors": [{"name": "body", "css": "body", "html": True}]}}, + {"function": "scrapling::extract", "case": "foreign_content_serialization", + "request": {"html": "<svg viewBox='0 0 1 1'><foreignObject><DIV xlink:href='x'>T</DIV></foreignObject></svg>", + "selectors": [{"name": "svg", "css": "svg", "html": True}]}}, + {"function": "scrapling::extract", "case": "entities_and_invalid_codepoints", + "request": {"html": "<p>&copy; &apos; &#0; &#xD800; &notanentity;</p>", + "selectors": [{"name": "text", "xpath": "//p/text()", "all": True}, + {"name": "html", "css": "p", "html": True}]}}, + {"function": "scrapling::extract", "case": "comments_removed_and_text_merged", + "request": {"html": "<p>a<!--gone-->b<![CDATA[c]]>d</p>", + "selectors": [{"name": "text", "xpath": "//p/text()", "all": True}, + {"name": "html", "css": "p", "html": True}]}}, + {"function": "scrapling::extract", "case": "template_nested_content", + "request": {"html": "<template><table><td>T</template><p>P", "selectors": [ + {"name": "template", "css": "template", "html": True}, + {"name": "td", "xpath": "//template//td", "all": True}, + ]}}, + # CSS is translated with cssselect 1.5 then evaluated as XPath. + {"function": "scrapling::css", "case": "sibling_text_pseudo", + "request": {"html": "<h1>H</h1>x<p>A<b>B</b>C</p><p>D</p>", "query": "h1 + p::text"}}, + {"function": "scrapling::css", "case": "general_sibling_attr_pseudo", + "request": {"html": "<h1>H</h1><p data-x='a'>A</p><p data-x='b'>B</p>", "query": "h1 ~ p::attr(data-x)"}}, + {"function": "scrapling::css", "case": "nth_not_and_attribute_operators", + "request": {"html": "<ul><li class='x' data-v='en-us'>A</li><li data-v='en'>B</li><li data-v='fr'>C</li></ul>", + "query": "li:nth-child(2):not(.x)[data-v|='en']", "first": True}}, + {"function": "scrapling::css", "case": "grouped_selector_document_order", + "request": {"html": "<p>P</p><h1>H</h1><p>Q</p>", "query": "h1, p"}}, + {"function": "scrapling::find-similar", "case": "subselector_scope_cannot_escape", + "request": {"html": "<main><section><a>inside</a></section></main><a>outside</a>", "anchor": "section", + "selectors": [{"name": "escaped", "css": "body a", "all": True}, + {"name": "inside", "css": "a", "all": True}]}}, + # XPath 1.0 axes, functions, coercion, scalar quirks, and errors. + {"function": "scrapling::xpath", "case": "ancestor_axis_reverse_position", + "request": {"html": "<main id='m'><section id='s'><p>P</p></section></main>", "query": "//p/ancestor::*[1]/@id"}}, + {"function": "scrapling::xpath", "case": "preceding_axis_reverse_position", + "request": {"html": "<p id='a'>A</p><p id='b'>B</p><p id='c'>C</p>", "query": "//p[@id='c']/preceding::p[1]/@id"}}, + {"function": "scrapling::xpath", "case": "following_axis_document_order", + "request": {"html": "<div><i>A</i></div><p>B</p><p>C</p>", "query": "//i/following::p/text()"}}, + {"function": "scrapling::xpath", "case": "attribute_wildcard_order", + "request": {"html": "<p z='1' a='2' m='3'>P</p>", "query": "//p/@*"}}, + {"function": "scrapling::xpath", "case": "predicate_string_functions", + "request": {"html": "<p> Alpha beta </p><p>Gamma</p>", + "query": "//p[starts-with(normalize-space(.), 'Alpha') and string-length(normalize-space(.)) = 10]"}}, + {"function": "scrapling::xpath", "case": "predicate_arithmetic_and_round", + "request": {"html": "<i>1</i><i>2</i><i>3</i><i>4</i>", "query": "//i[position() = round(last() div 2)]"}}, + {"function": "scrapling::xpath", "case": "global_parenthesized_position", + "request": {"html": "<div><p>A</p><p>B</p></div><section><p>C</p></section>", "query": "(//p)[2]"}}, + {"function": "scrapling::xpath", "case": "string_scalar_splits_into_text_nodes", + "request": {"html": "<p>Alpha</p>", "query": "string(//p)"}}, + {"function": "scrapling::xpath", "case": "false_scalar_becomes_empty", + "request": {"html": "<p>Alpha</p>", "query": "boolean(//nope)"}}, + {"function": "scrapling::xpath", "case": "true_scalar_type_error", + "request": {"html": "<p>Alpha</p>", "query": "boolean(//p)"}}, + {"function": "scrapling::xpath", "case": "number_scalar_type_error", + "request": {"html": "<p>Alpha</p>", "query": "count(//p)"}}, + {"function": "scrapling::xpath", "case": "unknown_function_error", + "request": {"html": "<p>Alpha</p>", "query": "no-such-function()"}}, +] + + +def gen_behavior(output: Path = WORKER) -> None: + from src.handlers import create_handlers + + handlers = create_handlers(lambda: {}) + for name in ("basic", "edge", "messy"): + CORPUS[name] = (WORKER / "tests/corpus" / f"{name}.html").read_text() + n = 0 + for entry in MATRIX: + req = dict(entry["request"]) + # Named corpus file, or (to-markdown's scope-root/pseudo-selector + # fixtures) inline HTML that names no corpus entry at all. + req["html"] = CORPUS.get(req["html"], req["html"]) + fn = entry["function"] + try: + resp = {"ok": asyncio.run(handlers[fn.split("::", 1)[1].replace("-", "_")](req))} + except Exception as exc: # noqa: BLE001 — error text is part of the contract + resp = {"err": str(exc)} + dump( + output / "tests/golden/behavior" / fn.split("::")[1] / (entry["case"] + ".json"), + { + "function": wire_id(fn), + "case": entry["case"], + "request": req, + **resp, + }, + ) + n += 1 + print(f"wrote {n} behavior fixtures") + + +class _BrowserFixtureHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 - stdlib handler API + body = (WORKER / "tests/corpus/browser_visual.html").read_bytes() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args) -> None: + pass + + +class _ThreadingServer(socketserver.ThreadingMixIn, http.server.HTTPServer): + daemon_threads = True + + +def gen_browser(output: Path = WORKER) -> None: + """Generate deterministic screenshot fixtures through the public wrapper.""" + from PIL import Image + from src.handlers import create_handlers + + server = _ThreadingServer(("127.0.0.1", 0), _BrowserFixtureHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + origin = f"http://127.0.0.1:{server.server_port}" + cfg = {"defaults": {"headless": True, "network_idle": False, "proxy": "", "include_html": False}} + handler = create_handlers(lambda: cfg)["screenshot"] + cases = [ + ("dynamic-viewport-png", {"fetcher": "dynamic", "format": "png", "full_page": False}), + ("dynamic-full-png", {"fetcher": "dynamic", "format": "png", "full_page": True}), + ("stealthy-viewport-png", {"fetcher": "stealthy", "format": "png", "full_page": False}), + ("stealthy-full-jpeg", {"fetcher": "stealthy", "format": "jpeg", "full_page": True}), + ] + destination = output / "tests/golden/browser" + destination.mkdir(parents=True, exist_ok=True) + records = [] + try: + for name, options in cases: + request = {"url": f"{origin}/visual", "retries": 1, **options} + response = asyncio.run(handler(request)) + blocks = [] + image_index = 0 + for block in response["content"]: + if block["type"] != "image": + blocks.append({**block, "text": block["text"].replace(origin, "{origin}")}) + continue + image_index += 1 + data = base64.b64decode(block["data"]) + suffix = "jpg" if block["mime"] == "image/jpeg" else "png" + filename = f"{name}-{image_index}.{suffix}" + (destination / filename).write_bytes(data) + with Image.open(destination / filename) as image: + dimensions = [image.width, image.height] + blocks.append({ + "type": "image", + "mime": block["mime"], + "file": filename, + "bytes": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + "dimensions": dimensions, + }) + records.append({ + "case": name, + "request": {**request, "url": "{origin}/visual"}, + "response": { + "content": blocks, + "url": response["url"].replace(origin, "{origin}"), + "mime": response["mime"], + }, + }) + finally: + server.shutdown() + server.server_close() + thread.join() + dump(destination / "manifest.json", {"cases": records}) + print(f"wrote {len(cases)} browser fixtures") + + +def check_browser() -> None: + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) + gen_browser(output) + fresh = output / "tests/golden/browser" + committed = WORKER / "tests/golden/browser" + names = {path.name for path in fresh.iterdir()} | {path.name for path in committed.iterdir()} + drift = [name for name in names if not (fresh / name).exists() or not (committed / name).exists() + or (fresh / name).read_bytes() != (committed / name).read_bytes()] + if drift: + raise SystemExit(f"browser goldens are stale: {sorted(drift)}") + print("browser goldens are current") + + +def check() -> None: + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) + gen_schemas(output) + gen_behavior(output) + generated = output / "tests/golden" + committed = WORKER / "tests/golden" + drift = [] + for fresh in generated.rglob("*.json"): + relative = fresh.relative_to(generated) + checked_in = committed / relative + if not checked_in.exists() or fresh.read_bytes() != checked_in.read_bytes(): + drift.append(str(relative)) + generated_behavior = { + path.relative_to(generated / "behavior") for path in (generated / "behavior").rglob("*.json") + } + committed_behavior = { + path.relative_to(committed / "behavior") for path in (committed / "behavior").rglob("*.json") + } + drift.extend(str(path) for path in generated_behavior ^ committed_behavior) + if drift: + raise SystemExit(f"goldens are stale: {sorted(set(drift))}") + print("goldens are current") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument( + "mode", + choices=["schemas", "behavior", "browser", "check", "browser-check"], + nargs="?", + default="schemas", + ) + parser.add_argument( + "--parser-runtime", + action="store_true", + help="verify immutable parser inputs but not host fonts, locale, or timezone", + ) + args = parser.parse_args() + random.seed(0) + if os.environ.get("PYTHONHASHSEED") != "0": + os.execve(sys.executable, [sys.executable, *sys.argv], {**os.environ, "PYTHONHASHSEED": "0"}) + verify_command = [sys.executable, HERE / "verify_oracle.py"] + if args.parser_runtime: + verify_command.append("--parser-runtime") + subprocess.run(verify_command, check=True) + { + "schemas": gen_schemas, + "behavior": gen_behavior, + "browser": gen_browser, + "check": check, + "browser-check": check_browser, + }[args.mode]() diff --git a/browser/scripts/verify_oracle.py b/browser/scripts/verify_oracle.py new file mode 100644 index 000000000..d83fc528b --- /dev/null +++ b/browser/scripts/verify_oracle.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""Write or verify the frozen standalone-worker oracle manifest.""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.metadata +import json +import locale +import os +import re +import subprocess +import sys +from pathlib import Path + + +WORKER = Path(__file__).resolve().parent.parent +REPO = WORKER.parent +MANIFEST = WORKER / "oracle/manifest.json" +LOCK = WORKER / "oracle/requirements.lock" +ASSET_SUFFIXES = {".dat", ".json", ".pem", ".txt", ".xz", ".zip"} +BROWSERS = ( + ( + "chromium-linux-x64", + "pw-chromium-1223-linux-x64.zip", + "https://cdn.playwright.dev/builds/cft/148.0.7778.96/linux64/chrome-linux64.zip", + ), + ( + "chromium-headless-shell-linux-x64", + "pw-headless-1223-linux-x64.zip", + "https://cdn.playwright.dev/builds/cft/148.0.7778.96/linux64/chrome-headless-shell-linux64.zip", + ), + ( + "ffmpeg-linux-x64", + "pw-ffmpeg-1011-linux-x64.zip", + "https://cdn.playwright.dev/dbazure/download/playwright/builds/ffmpeg/1011/ffmpeg-linux.zip", + ), + ( + "chromium-linux-arm64", + "pw-chromium-1223-linux-arm64.zip", + "https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1223/chromium-linux-arm64.zip", + ), + ( + "chromium-headless-shell-linux-arm64", + "pw-headless-1223-linux-arm64.zip", + "https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1223/chromium-headless-shell-linux-arm64.zip", + ), + ( + "ffmpeg-linux-arm64", + "pw-ffmpeg-1011-linux-arm64.zip", + "https://cdn.playwright.dev/dbazure/download/playwright/builds/ffmpeg/1011/ffmpeg-linux-arm64.zip", + ), +) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def file_record(path: Path, name: str | None = None) -> dict[str, object]: + return {"path": name or str(path), "size": path.stat().st_size, "sha256": sha256(path)} + + +def records_digest(records: list[dict[str, object]]) -> str: + digest = hashlib.sha256() + for record in records: + digest.update(str(record["path"]).encode()) + digest.update(b"\0") + digest.update(str(record["sha256"]).encode()) + digest.update(b"\0") + return digest.hexdigest() + + +def source_manifest() -> dict[str, object]: + output = subprocess.check_output( + ["git", "ls-files", "-z", "scrapling"], cwd=REPO + ) + paths = [Path(item.decode()) for item in output.split(b"\0") if item] + files = [file_record(REPO / path, str(path)) for path in paths] + return {"version": "0.2.6", "sha256": records_digest(files), "files": files} + + +def canonical_name(value: str) -> str: + return re.sub(r"[-_.]+", "-", value).lower() + + +def package_manifest() -> tuple[list[dict[str, object]], list[dict[str, object]], str]: + packages = [] + assets = [] + runtime_digest = hashlib.sha256() + for distribution in sorted( + importlib.metadata.distributions(), + key=lambda item: canonical_name(item.metadata["Name"]), + ): + name = canonical_name(distribution.metadata["Name"]) + files = [] + for relative in sorted(distribution.files or (), key=str): + path = Path(distribution.locate_file(relative)) + if not path.is_file(): + continue + record = file_record(path, str(relative)) + files.append(record) + if path.suffix.lower() in ASSET_SUFFIXES: + assets.append({"package": name, **record}) + relative_name = str(relative) + if not relative_name.startswith("../../../bin/") and not relative_name.endswith( + ".dist-info/RECORD" + ): + for value in (name, relative_name, str(record["sha256"])): + runtime_digest.update(value.encode()) + runtime_digest.update(b"\0") + packages.append( + { + "name": name, + "version": distribution.version, + "files": len(files), + "bytes": sum(int(item["size"]) for item in files), + "sha256": records_digest(files), + } + ) + return packages, assets, runtime_digest.hexdigest() + + +def font_manifest() -> list[dict[str, object]]: + output = subprocess.check_output(["fc-list", "--format=%{file}\n"], text=True) + paths = sorted({Path(item) for item in output.splitlines() if item}) + return [file_record(path) for path in paths] + + +def timezone_manifest() -> dict[str, object]: + path = Path("/etc/localtime").resolve() + prefix = Path("/usr/share/zoneinfo") + try: + name = str(path.relative_to(prefix)) + except ValueError: + name = os.environ.get("TZ", str(path)) + return {"name": name, **file_record(path)} + + +def browser_manifest(archive_dir: Path) -> list[dict[str, object]]: + records = [] + for name, filename, url in BROWSERS: + path = archive_dir / filename + if not path.is_file(): + raise SystemExit(f"missing browser oracle archive: {path}") + records.append({"name": name, "url": url, **file_record(path, filename)}) + return records + + +def snapshot(archive_dir: Path) -> dict[str, object]: + packages, assets, parser_runtime_sha256 = package_manifest() + fonts = font_manifest() + certifi = importlib.import_module("certifi") + executable = Path(sys.executable).resolve() + return { + "format": 1, + "source": source_manifest(), + "python": { + "version": sys.version.split()[0], + "implementation": sys.implementation.name, + "executable": file_record(executable), + "parser_runtime_sha256": parser_runtime_sha256, + "packages": packages, + "requirements_lock": file_record(LOCK, "oracle/requirements.lock"), + }, + "browser": { + "playwright_revision": "1223", + "chromium_version": "148.0.7778.96", + "archives": browser_manifest(archive_dir), + }, + "assets": assets, + "host": { + "locale": locale.setlocale(locale.LC_ALL, ""), + "locale_environment": { + key: os.environ.get(key, "") + for key in ("LANG", "LC_ALL", "LC_CTYPE") + }, + "timezone": timezone_manifest(), + "ca_bundle": file_record(Path(certifi.where()), "certifi/cacert.pem"), + "fonts_sha256": records_digest(fonts), + "fonts": fonts, + }, + "determinism": {"PYTHONHASHSEED": "0", "random_seed": 0}, + } + + +def verify_archives(expected: dict[str, object], archive_dir: Path) -> None: + actual = browser_manifest(archive_dir) + if actual != expected["browser"]["archives"]: + raise SystemExit("browser oracle archives differ from oracle/manifest.json") + + +def verify(archive_dir: Path | None) -> None: + expected = json.loads(MANIFEST.read_text()) + # Archive bytes are release inputs, not required to regenerate parse-only + # goldens. Reuse the frozen entries while comparing everything local. + current = snapshot(archive_dir or Path("/nonexistent")) if archive_dir else None + if current is None: + packages, assets, parser_runtime_sha256 = package_manifest() + fonts = font_manifest() + certifi = importlib.import_module("certifi") + executable = Path(sys.executable).resolve() + current = { + **expected, + "source": source_manifest(), + "python": { + "version": sys.version.split()[0], + "implementation": sys.implementation.name, + "executable": file_record(executable), + "parser_runtime_sha256": parser_runtime_sha256, + "packages": packages, + "requirements_lock": file_record(LOCK, "oracle/requirements.lock"), + }, + "assets": assets, + "host": { + "locale": locale.setlocale(locale.LC_ALL, ""), + "locale_environment": { + key: os.environ.get(key, "") + for key in ("LANG", "LC_ALL", "LC_CTYPE") + }, + "timezone": timezone_manifest(), + "ca_bundle": file_record(Path(certifi.where()), "certifi/cacert.pem"), + "fonts_sha256": records_digest(fonts), + "fonts": fonts, + }, + } + if current != expected: + raise SystemExit("oracle environment differs from oracle/manifest.json") + if archive_dir: + verify_archives(expected, archive_dir) + print("oracle environment verified") + + +def verify_parser_runtime() -> None: + """Verify inputs that can affect parse differentials, excluding host/browser data.""" + expected = json.loads(MANIFEST.read_text()) + packages, assets, parser_runtime_sha256 = package_manifest() + current = { + "source": source_manifest(), + "python": { + "version": sys.version.split()[0], + "implementation": sys.implementation.name, + "parser_runtime_sha256": parser_runtime_sha256, + "packages": [ + {"name": item["name"], "version": item["version"]} + for item in packages + ], + "requirements_lock": file_record(LOCK, "oracle/requirements.lock"), + }, + "assets": assets, + } + frozen = { + "source": expected["source"], + "python": { + "version": expected["python"]["version"], + "implementation": expected["python"]["implementation"], + "parser_runtime_sha256": expected["python"]["parser_runtime_sha256"], + "packages": [ + {"name": item["name"], "version": item["version"]} + for item in expected["python"]["packages"] + ], + "requirements_lock": expected["python"]["requirements_lock"], + }, + "assets": expected["assets"], + } + if current != frozen: + raise SystemExit("parser oracle runtime differs from oracle/manifest.json") + print("parser oracle runtime verified") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--write", action="store_true") + parser.add_argument("--archive-dir", type=Path) + parser.add_argument("--parser-runtime", action="store_true") + args = parser.parse_args() + if args.write: + if not args.archive_dir: + parser.error("--write requires --archive-dir") + MANIFEST.write_text(json.dumps(snapshot(args.archive_dir), indent=2) + "\n") + print(f"wrote {MANIFEST}") + elif args.parser_runtime: + if args.archive_dir: + parser.error("--parser-runtime does not accept --archive-dir") + verify_parser_runtime() + else: + verify(args.archive_dir) + + +if __name__ == "__main__": + main() diff --git a/browser/skills/SKILL.md b/browser/skills/SKILL.md index 5835192f2..378ca5e4f 100644 --- a/browser/skills/SKILL.md +++ b/browser/skills/SKILL.md @@ -3,13 +3,20 @@ name: browser description: >- Interactive Chromium sessions for reading and driving real web pages: open a URL, read the page as text, click and type, and read the page's own console - and network history. Reach for it when a task involves a running web app, - especially "why is this page broken". + and network history. Also scrapes: HTTP and browser fetching, screenshots, + persistent sessions, crawling, and CSS/XPath/regex parsing of HTML you + already have. Reach for it when a task involves a running web app, + especially "why is this page broken", or when pulling data off the web. --- # browser -The browser worker runs real Chromium sessions on the bus. Start a session, +The browser worker does two things. It runs real Chromium sessions on the bus +(`browser::*`), and it parses HTML natively without a browser +(`browser::*` — CSS/XPath/regex queries, element search, +HTML→Markdown, over any HTML string you already have). + +Start a session, navigate, and the page becomes data: `browser::snapshot` returns an accessibility outline whose `[ref=eN]` handles feed straight into `browser::act`, and everything the page logs (console calls, uncaught @@ -38,9 +45,16 @@ on navigation; re-snapshot before acting after any page change. ## Boundaries -- One-shot fetching and scraping belong to `web::fetch` (plain HTTP) and the - scrapling worker (stealth fetching and bulk extraction). Do not start a - browser session just to read a static page once. +- Do not start a browser session just to read a page once. One-shot fetching + is `browser::fetch` (no browser) or `browser::dynamic-fetch` + (Chromium, when the page needs JS). Sessions are for flows that need state + between steps. +- Parsing HTML you already have needs neither a session nor a fetch: use the + `browser::*` parse function below. Starting Chromium to run a + CSS selector over a string you are already holding is pure waste. +- `solve_cloudflare` is available on `browser::stealthy-fetch` and stealthy + Scrapling sessions. Use `browser::handoff` for challenges in an interactive + session or when automated solving does not clear the page. - Attach mode reaches the user's real browser profile with its logged-in sessions. It is disabled unless `allow_attach` is set, and adoption is exclusive (one session per tab) so two sessions never fight over a tab. @@ -107,6 +121,85 @@ on navigation; re-snapshot before acting after any page change. - `browser::styles::read` / `browser::styles::write` — computed styles and live inline CSS edits on one element. +### Fetching, sessions and crawl + +These reach the network, so they need approval. All return one envelope — +`{status, url, headers, cookies, encoding}` — and can extract or render inline +via `selectors` / `format: markdown|text` / `include_html`, so you rarely need +a second call to parse what you fetched. Each takes a single `url` or a bulk +`urls` list. + +- `browser::fetch` — plain HTTP, no browser. The default choice: fastest, + cheapest. Safe mode uses bounded native HTTP; certified compat mode uses the + frozen curl-impersonate wire behavior. +- `browser::dynamic-fetch` — real Chromium over CDP, for pages that + need JavaScript. Supports `wait_selector` (+ `wait_selector_state`), + `network_idle`, and a plain `wait`. +- `browser::stealthy-fetch` — same, plus masking of the automation + tells a page can read. Escalate here only when `dynamic-fetch` is detected. +- `browser::screenshot-url` — page as image tiles (≤1024px wide, ≤6 + tiles); says so in the caption when a page is taller than the budget. +- `browser::session-open` / `session-fetch` / `session-close` / + `session-list` — keep cookies and browser state across fetches. + HTTP, dynamic and stealthy types are private FIFO sessions with UUID4 hex + ids; they never appear in `browser::sessions::list` and reject interactive + ids. Close sessions when done. +- `browser::crawl` — breadth-first from `start_urls`, same-domain by + default, capped by `max_pages` (20) and `max_depth` (2). The response holds + only a ≤10-item sample; read the rest from the stream it names. + +Safe mode refuses private, loopback and cloud-metadata addresses on every one +of these connections (including redirects and crawl hops). To scrape a local +dev server the operator must set `browser.scrapling.allow_loopback` in worker +config. Compat mode reproduces the standalone worker's unrestricted network +behavior and is for trusted calls. + +### HTML parsing — no session, no browser, no network + +These take an `html` string and never touch Chromium. Use them on HTML from +any source (a fetch body, a file, a page you already read). + +- `browser::extract` — declarative selector list in one call: + each entry names a `css`/`xpath`/`regex` plus optional `attr`/`html`/`all`, + and the response is a `{name: value}` map. The right default when pulling + several fields off one document. +- `browser::css` / `browser::xpath` — one query; + `first: true` returns a scalar, otherwise an array. `attr` pulls an + attribute instead of text. +- `browser::regex` — regex over the document's visible text. +- `browser::find` — element search by tag/attribute filters + (+ optional text regex), BeautifulSoup-style. +- `browser::find-by-text` / `browser::find-by-regex` — + find elements by their visible text. Responses carry generated css/xpath + selectors for each hit, so you can feed one straight back into a query. +- `browser::find-similar` — give one example element, get its + structural siblings. The fast path for "extract every card/row on this + page" without hand-writing a selector. +- `browser::describe` — inspect the first match: attributes, + class list, generated selectors, parent/child/sibling counts. +- `browser::to-markdown` — HTML → compact Markdown (or text), with + an optional CSS scope and a main-content cleaner. Use it to shrink a page + before putting it in context. + +`adaptive: true` persists element identities in the configured SQLite file. +Parse calls are auto-allowed, so do not assume parsing is side-effect-free +when adaptive tracking is enabled. Safe mode enforces the configured database +quota; compat mode preserves the standalone worker's unbounded behavior. + +### Safe and compat modes + +`browser.scrapling.security_mode` defaults to `safe`. Safe mode keeps SSRF, +TLS, proxy, response-size, timeout, and adaptive-database policy checks; an +option the safe backend cannot enforce is refused with an actionable error. +Compat is eligible only on certified Linux x86_64/aarch64 builds containing +the frozen curl-impersonate and Chromium artifacts. Other targets reject it, +and a Tier-1 build missing an artifact reports a capability error instead of +silently using the safe transport. + +Native ids are `browser::<leaf>`. Map `scrapling::screenshot` to +`browser::screenshot-url`; `browser::screenshot` is the interactive-session +function. Crawl's default stream is `browser::crawl`. + ## Workflow: inspect before acting 1. Snapshot first; act on refs from the latest snapshot, never from memory diff --git a/browser/src/config.rs b/browser/src/config.rs index 83e63316d..e5f76f014 100644 --- a/browser/src/config.rs +++ b/browser/src/config.rs @@ -13,6 +13,101 @@ use serde::{Deserialize, Serialize}; pub type SharedConfig = Arc<ArcSwap<WorkerConfig>>; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum SecurityMode { + #[default] + Safe, + Compat, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct ScraplingDefaults { + pub impersonate: String, + pub headless: bool, + pub network_idle: bool, + pub proxy: String, + pub include_html: bool, +} + +impl Default for ScraplingDefaults { + fn default() -> Self { + Self { + impersonate: "chrome".to_string(), + headless: true, + network_idle: false, + proxy: String::new(), + include_html: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct ScraplingConfig { + pub security_mode: SecurityMode, + pub chromium_executable: String, + pub allow_loopback: bool, + pub defaults: ScraplingDefaults, + pub max_bulk_concurrency: u64, + pub max_sessions: u64, + pub session_idle_timeout_s: u64, + pub adaptive_storage_path: String, + pub adaptive_max_bytes: u64, + /// Append the browser::* scraping guidance to agent system prompts. + /// Hot-applies: flipping it in the console binds/unbinds the + /// pre-generate hook live, no restart (same knob the Python scrapling + /// worker and the fp/web workers carry). + pub inject_guidance: bool, +} + +impl Default for ScraplingConfig { + fn default() -> Self { + Self { + security_mode: SecurityMode::Safe, + chromium_executable: String::new(), + allow_loopback: false, + defaults: ScraplingDefaults::default(), + max_bulk_concurrency: 5, + max_sessions: 8, + session_idle_timeout_s: 900, + adaptive_storage_path: "./data/scrapling/elements.db".to_string(), + adaptive_max_bytes: 268_435_456, + inject_guidance: true, + } + } +} + +impl ScraplingConfig { + pub fn startup_snapshot(&self) -> ScraplingStartupConfig { + ScraplingStartupConfig { + max_sessions: self.max_sessions, + session_idle_timeout_s: self.session_idle_timeout_s, + adaptive_storage_path: self.adaptive_storage_path.clone(), + } + } + + pub fn adaptive_quota(&self) -> Option<u64> { + (self.security_mode == SecurityMode::Safe).then_some(self.adaptive_max_bytes) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScraplingStartupConfig { + pub max_sessions: u64, + pub session_idle_timeout_s: u64, + pub adaptive_storage_path: String, +} + +pub const fn scrapling_compat_supported() -> bool { + cfg!(all( + feature = "scrapling-compat", + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") + )) +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(default)] pub struct WorkerConfig { @@ -62,6 +157,19 @@ pub struct WorkerConfig { /// browser and adopt its tabs. Off by default: attaching reaches the /// user's real profile with its logged-in sessions, so it is opt-in. pub allow_attach: bool, + /// Native Scrapling compatibility settings. These are isolated from the + /// interactive browser runtime above. + pub scrapling: ScraplingConfig, + /// Internal compatibility projection for routes that have not yet moved + /// to the private Scrapling runtime. It is populated from `scrapling` and + /// is deliberately absent from the public configuration schema/wire. + #[serde(skip)] + #[schemars(skip)] + pub allow_loopback: bool, + /// Internal compatibility projection; see `allow_loopback`. + #[serde(skip)] + #[schemars(skip)] + pub max_bulk_concurrency: u64, } impl Default for WorkerConfig { @@ -82,6 +190,9 @@ impl Default for WorkerConfig { allowed_schemes: vec!["http".to_string(), "https".to_string(), "file".to_string()], max_snapshot_nodes: 2_000, allow_attach: false, + scrapling: ScraplingConfig::default(), + allow_loopback: false, + max_bulk_concurrency: 5, } } } @@ -97,17 +208,41 @@ impl WorkerConfig { /// under a `browser` wrapper or flat — accept both. pub fn from_json(v: &serde_json::Value) -> Result<WorkerConfig, String> { let inner = v.get("browser").unwrap_or(v); - serde_json::from_value(inner.clone()).map_err(|e| format!("invalid browser config: {e}")) + let mut config: WorkerConfig = serde_json::from_value(inner.clone()) + .map_err(|e| format!("invalid browser config: {e}"))?; + config.validate()?; + config.sync_scrapling_projection(); + Ok(config) } pub fn to_json(&self) -> serde_json::Value { serde_json::to_value(self).expect("WorkerConfig serializes") } - pub fn into_shared(self) -> SharedConfig { + pub fn into_shared(mut self) -> SharedConfig { + self.sync_scrapling_projection(); Arc::new(ArcSwap::from_pointee(self)) } + pub fn validate(&self) -> Result<(), String> { + self.validate_with_compat_support(scrapling_compat_supported()) + } + + fn validate_with_compat_support(&self, compat_supported: bool) -> Result<(), String> { + if self.scrapling.security_mode == SecurityMode::Compat && !compat_supported { + return Err( + "browser.scrapling.security_mode=compat is unsupported on this target; compat requires Tier-1 Linux x86_64 or aarch64" + .to_string(), + ); + } + Ok(()) + } + + fn sync_scrapling_projection(&mut self) { + self.allow_loopback = self.scrapling.allow_loopback; + self.max_bulk_concurrency = self.scrapling.max_bulk_concurrency; + } + /// Clamp a caller-supplied timeout to the configured ceiling, defaulting /// when omitted. pub fn clamp_timeout(&self, requested: Option<u64>) -> u64 { @@ -139,6 +274,32 @@ mod tests { assert_eq!(c.allowed_schemes, vec!["http", "https", "file"]); assert_eq!(c.max_snapshot_nodes, 2_000); assert!(!c.allow_attach); + assert_eq!(c.scrapling.security_mode, SecurityMode::Safe); + assert_eq!(c.scrapling.chromium_executable, ""); + assert!(!c.scrapling.allow_loopback); + assert_eq!(c.scrapling.defaults.impersonate, "chrome"); + assert!(c.scrapling.defaults.headless); + assert!(!c.scrapling.defaults.network_idle); + assert_eq!(c.scrapling.defaults.proxy, ""); + assert!(!c.scrapling.defaults.include_html); + assert_eq!(c.scrapling.max_bulk_concurrency, 5); + assert_eq!(c.scrapling.max_sessions, 8); + assert_eq!(c.scrapling.session_idle_timeout_s, 900); + assert_eq!( + c.scrapling.adaptive_storage_path, + "./data/scrapling/elements.db" + ); + assert_eq!(c.scrapling.adaptive_max_bytes, 268_435_456); + assert_eq!(c.scrapling.adaptive_quota(), Some(268_435_456)); + } + + #[test] + fn compat_keeps_the_oracles_unbounded_adaptive_storage() { + let config = ScraplingConfig { + security_mode: SecurityMode::Compat, + ..ScraplingConfig::default() + }; + assert_eq!(config.adaptive_quota(), None); } #[test] @@ -168,6 +329,121 @@ mod tests { assert!(!c.headless); } + #[test] + fn nested_scrapling_values_parse_without_changing_interactive_values() { + let value = serde_json::json!({ + "browser": { + "headless": false, + "max_sessions": 3, + "scrapling": { + "allow_loopback": true, + "max_bulk_concurrency": 2, + "max_sessions": 7, + "session_idle_timeout_s": 45, + "adaptive_storage_path": "/tmp/scrapling-test.db", + "adaptive_max_bytes": 1024, + "defaults": { + "impersonate": "firefox", + "headless": false, + "network_idle": true, + "proxy": "http://proxy.test:8080", + "include_html": true + } + } + } + }); + + let config = WorkerConfig::from_json(&value).unwrap(); + assert!(!config.headless); + assert_eq!(config.max_sessions, 3); + assert!(config.scrapling.allow_loopback); + assert_eq!(config.scrapling.max_bulk_concurrency, 2); + assert_eq!(config.scrapling.max_sessions, 7); + assert_eq!(config.scrapling.session_idle_timeout_s, 45); + assert_eq!( + config.scrapling.adaptive_storage_path, + "/tmp/scrapling-test.db" + ); + assert_eq!(config.scrapling.adaptive_max_bytes, 1024); + assert_eq!(config.scrapling.defaults.impersonate, "firefox"); + assert!(!config.scrapling.defaults.headless); + assert!(config.scrapling.defaults.network_idle); + assert_eq!(config.scrapling.defaults.proxy, "http://proxy.test:8080"); + assert!(config.scrapling.defaults.include_html); + assert!(config.allow_loopback); + assert_eq!(config.max_bulk_concurrency, 2); + } + + #[test] + fn serialized_config_exposes_scrapling_settings_only_in_nested_block() { + let value = WorkerConfig::default().to_json(); + assert!(value.get("scrapling").is_some()); + assert!(value.get("allow_loopback").is_none()); + assert!(value.get("max_bulk_concurrency").is_none()); + } + + #[test] + fn startup_snapshot_owns_frozen_session_and_adaptive_values() { + let mut config = WorkerConfig::default(); + config.scrapling.max_sessions = 6; + config.scrapling.session_idle_timeout_s = 123; + config.scrapling.adaptive_storage_path = "/tmp/first.db".to_string(); + + let snapshot = config.scrapling.startup_snapshot(); + config.scrapling.max_sessions = 9; + config.scrapling.session_idle_timeout_s = 456; + config.scrapling.adaptive_storage_path = "/tmp/second.db".to_string(); + + assert_eq!(snapshot.max_sessions, 6); + assert_eq!(snapshot.session_idle_timeout_s, 123); + assert_eq!(snapshot.adaptive_storage_path, "/tmp/first.db"); + } + + #[test] + fn compat_is_explicitly_rejected_when_target_is_not_tier_one() { + let mut config = WorkerConfig::default(); + config.scrapling.security_mode = SecurityMode::Compat; + let error = config.validate_with_compat_support(false).unwrap_err(); + assert_eq!( + error, + "browser.scrapling.security_mode=compat is unsupported on this target; compat requires Tier-1 Linux x86_64 or aarch64" + ); + } + + #[test] + fn compat_validation_tracks_the_compiled_target() { + let result = WorkerConfig::from_json(&serde_json::json!({ + "scrapling": {"security_mode": "compat"} + })); + if scrapling_compat_supported() { + assert_eq!( + result.unwrap().scrapling.security_mode, + SecurityMode::Compat + ); + } else { + assert_eq!( + result.unwrap_err(), + "browser.scrapling.security_mode=compat is unsupported on this target; compat requires Tier-1 Linux x86_64 or aarch64" + ); + } + } + + #[test] + fn safe_mode_is_accepted_on_every_target() { + WorkerConfig::default() + .validate_with_compat_support(false) + .unwrap(); + } + + #[test] + fn unknown_security_mode_is_rejected() { + let error = WorkerConfig::from_json(&serde_json::json!({ + "scrapling": {"security_mode": "unsafe"} + })) + .unwrap_err(); + assert!(error.contains("unknown variant `unsafe`"), "{error}"); + } + #[test] fn clamp_timeout_defaults_and_ceils() { let c = WorkerConfig::default(); @@ -183,5 +459,38 @@ mod tests { assert!(props.get("executable").is_some()); assert!(props.get("headless").is_some()); assert!(props.get("allowed_schemes").is_some()); + let scrapling = &props["scrapling"]; + assert_eq!( + scrapling["default"], + serde_json::to_value(WorkerConfig::default().scrapling).unwrap() + ); + assert_eq!( + scrapling["allOf"][0]["$ref"], + "#/definitions/ScraplingConfig" + ); + let scrapling_properties = &s["definitions"]["ScraplingConfig"]["properties"]; + let names: std::collections::BTreeSet<_> = scrapling_properties + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + names, + std::collections::BTreeSet::from([ + "adaptive_max_bytes", + "adaptive_storage_path", + "allow_loopback", + "chromium_executable", + "defaults", + "inject_guidance", + "max_bulk_concurrency", + "max_sessions", + "security_mode", + "session_idle_timeout_s", + ]) + ); + assert!(props.get("allow_loopback").is_none()); + assert!(props.get("max_bulk_concurrency").is_none()); } } diff --git a/browser/src/configuration.rs b/browser/src/configuration.rs index 7bef16766..fc086899f 100644 --- a/browser/src/configuration.rs +++ b/browser/src/configuration.rs @@ -68,7 +68,11 @@ struct OnConfigChangeResponse { ok: bool, } -pub fn register_config_trigger(iii: &IIIClient, config: SharedConfig) -> Result<(), Error> { +pub fn register_config_trigger( + iii: &IIIClient, + config: SharedConfig, + guidance: crate::scrapling::GuidanceState, +) -> Result<(), Error> { let cfg = config.clone(); let engine = iii.clone(); iii.register_function( @@ -76,8 +80,9 @@ pub fn register_config_trigger(iii: &IIIClient, config: SharedConfig) -> Result< RegisterFunction::new_async(move |_req: OnConfigChangeRequest| { let cfg = cfg.clone(); let engine = engine.clone(); + let guidance = guidance.clone(); async move { - on_config_change(&engine, &cfg).await; + on_config_change(&engine, &cfg, &guidance).await; Ok::<OnConfigChangeResponse, Error>(OnConfigChangeResponse { ok: true }) } }) @@ -96,10 +101,19 @@ pub fn register_config_trigger(iii: &IIIClient, config: SharedConfig) -> Result< Ok(()) } -async fn on_config_change(iii: &IIIClient, config: &SharedConfig) { +async fn on_config_change( + iii: &IIIClient, + config: &SharedConfig, + guidance: &crate::scrapling::GuidanceState, +) { match fetch_config(iii).await { Ok(cfg) => { + crate::scrapling::adaptive::configure_quota(cfg.scrapling.adaptive_quota()); + let inject_guidance = cfg.scrapling.inject_guidance; config.store(std::sync::Arc::new(cfg)); + // Hot-apply: flipping browser.scrapling.inject_guidance in the + // console binds/unbinds the pre-generate guidance hook live. + crate::scrapling::apply_guidance(iii, guidance, inject_guidance); tracing::info!("browser configuration reloaded"); } Err(e) => tracing::error!(error = %e, "config-change: keeping previous config"), diff --git a/browser/src/lib.rs b/browser/src/lib.rs index 8b3f763e5..ddfbfcb87 100644 --- a/browser/src/lib.rs +++ b/browser/src/lib.rs @@ -1,12 +1,16 @@ //! Library surface for the `browser` worker: interactive Chromium sessions -//! on the iii bus. The binary (`src/main.rs`) is a thin boot sequence; -//! everything testable lives here. +//! on the iii bus, plus the native `browser::*` HTML-parsing +//! surface. The binary (`src/main.rs`) is a thin boot sequence; everything +//! testable lives here. pub mod config; pub mod configuration; pub mod events; pub mod functions; +pub mod logging; pub mod manifest; +pub mod scrapling; pub mod session; pub mod snapshot; +pub mod ssrf; pub mod ui; diff --git a/browser/src/logging.rs b/browser/src/logging.rs new file mode 100644 index 000000000..482704729 --- /dev/null +++ b/browser/src/logging.rs @@ -0,0 +1,99 @@ +//! Log-filter construction for the worker binary. +//! +//! chromiumoxide 0.9.1's protocol bindings lag the system Chromium, so events +//! carrying enum values added since (e.g. DOM.pseudoElementAdded with +//! `overscroll-backdrop` on Chrome 151) fail its untagged-enum deserialize and +//! its handler WARN-spams "WS Invalid message" on every such frame — dozens +//! per page load. The frames are dropped either way (`ignore_invalid_messages` +//! defaults on; command responses can't fail this way, only events), so the +//! WARN carries no signal an operator can act on. Demote that module to +//! `error` unless the operator's RUST_LOG addresses chromiumoxide explicitly. +//! +//! ponytail: known ceiling — a Network.loadingFailed carrying one of the +//! Chrome-151 corsError values is still silently dropped before our listener; +//! recovering it needs chromiumoxide regenerated against the newer protocol +//! (no such release yet; 0.9.1 is current). + +use tracing_subscriber::EnvFilter; + +/// The worker's env filter: RUST_LOG (default `info`), with +/// `chromiumoxide::handler` demoted to `error` unless RUST_LOG mentions +/// chromiumoxide — an explicit operator directive always wins. +pub fn env_filter(rust_log: Option<&str>) -> EnvFilter { + let mut filter = match rust_log { + Some(directives) => EnvFilter::new(directives), + None => EnvFilter::new("info"), + }; + if rust_log.is_none_or(|directives| !directives.contains("chromiumoxide")) { + filter = filter.add_directive( + "chromiumoxide::handler=error" + .parse() + .expect("static directive parses"), + ); + } + filter +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::sync::{Arc, Mutex}; + use tracing_subscriber::layer::SubscriberExt; + + #[derive(Clone, Default)] + struct Buffer(Arc<Mutex<Vec<u8>>>); + + impl Write for Buffer { + fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + fn captured(filter: EnvFilter) -> String { + let buffer = Buffer::default(); + let writer = buffer.clone(); + let subscriber = tracing_subscriber::registry().with(filter).with( + tracing_subscriber::fmt::layer() + .with_writer(move || writer.clone()) + .with_ansi(false), + ); + tracing::subscriber::with_default(subscriber, || { + tracing::warn!(target: "chromiumoxide::handler", "WS Invalid message"); + tracing::error!(target: "chromiumoxide::handler", "WS Connection error"); + tracing::warn!(target: "browser::session", "worker warn passes"); + }); + let bytes = buffer.0.lock().unwrap().clone(); + String::from_utf8(bytes).unwrap() + } + + #[test] + fn default_filter_drops_the_invalid_message_spam_but_keeps_errors() { + let out = captured(env_filter(None)); + assert!( + !out.contains("WS Invalid message"), + "spam not dropped:\n{out}" + ); + assert!( + out.contains("WS Connection error"), + "real errors lost:\n{out}" + ); + assert!( + out.contains("worker warn passes"), + "worker warns lost:\n{out}" + ); + } + + #[test] + fn explicit_chromiumoxide_directive_in_rust_log_wins() { + let out = captured(env_filter(Some("info,chromiumoxide=warn"))); + assert!( + out.contains("WS Invalid message"), + "operator's explicit directive was overridden:\n{out}" + ); + } +} diff --git a/browser/src/main.rs b/browser/src/main.rs index f9273e8fb..372243884 100644 --- a/browser/src/main.rs +++ b/browser/src/main.rs @@ -1,6 +1,7 @@ //! `browser` binary entry: connect, register configuration + fetch the -//! authoritative value, register the five `browser::*` trigger types and -//! twelve functions, start the idle sweep, then sleep until Ctrl+C. +//! authoritative value, register the `browser::*` trigger types and functions +//! plus the native `browser::*` parse surface, start the idle +//! sweep, then sleep until Ctrl+C. use std::sync::Arc; use std::time::Duration; @@ -13,7 +14,7 @@ use iii_sdk::{register_worker, InitOptions}; use browser::config::WorkerConfig; use browser::events::{self, IiiDeliverer}; use browser::session::Sessions; -use browser::{configuration, functions, manifest}; +use browser::{configuration, functions, manifest, scrapling}; #[derive(Parser, Debug)] #[command( @@ -53,11 +54,9 @@ async fn wait_for_shutdown_signal() -> Result<()> { #[tokio::main] async fn main() -> Result<()> { + let rust_log = std::env::var("RUST_LOG").ok(); tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) + .with_env_filter(browser::logging::env_filter(rust_log.as_deref())) .init(); let cli = Cli::parse(); @@ -120,10 +119,19 @@ async fn main() -> Result<()> { .await .map_err(anyhow::Error::msg) .context("loading browser configuration")?; + let scrapling_startup = cfg.scrapling.startup_snapshot(); + scrapling::adaptive::configure(&scrapling_startup.adaptive_storage_path) + .map_err(anyhow::Error::msg) + .context("configuring Scrapling adaptive storage")?; + scrapling::adaptive::configure_quota(cfg.scrapling.adaptive_quota()); tracing::info!( headless = cfg.headless, max_sessions = cfg.max_sessions, console_buffer = cfg.console_buffer, + scrapling_security_mode = ?cfg.scrapling.security_mode, + scrapling_max_sessions = scrapling_startup.max_sessions, + scrapling_session_idle_timeout_s = scrapling_startup.session_idle_timeout_s, + scrapling_adaptive_storage_path = %scrapling_startup.adaptive_storage_path, "loaded browser configuration" ); let shared = cfg.into_shared(); @@ -138,27 +146,45 @@ async fn main() -> Result<()> { let sessions = Sessions::new(shared.clone(), emitter, iii.clone()); functions::register_all(&iii, &sessions); - configuration::register_config_trigger(&iii, shared.clone()) + // Scrapling owns a private HTTP/dynamic/stealthy registry. Its ids never + // enter or control the interactive browser::sessions::* registry. + let scrapling_ctx = Arc::new(scrapling::net::Ctx::new(sessions.clone(), iii.clone())); + scrapling::register_all(&iii, &scrapling_ctx); + // The guidance hook FUNCTION is registered above (inert without a + // binding); the binding follows the inject_guidance knob — applied here + // at boot and re-applied by the config-change handler, so console flips + // take effect without a restart. + let guidance = scrapling::GuidanceState::default(); + scrapling::apply_guidance(&iii, &guidance, shared.load().scrapling.inject_guidance); + + configuration::register_config_trigger(&iii, shared.clone(), guidance) .context("registering configuration change trigger")?; // Injectable console UI — after the browser::* functions so the console // can attribute the assets. browser::ui::register(&iii); - // Idle sweep: stop sessions nobody has touched for idle_stop_ms. + // Idle sweep closes metadata and backends together in both registries. let sweep_sessions = sessions.clone(); + let sweep_ctx = scrapling_ctx.clone(); let sweep = tokio::spawn(async move { let mut tick = tokio::time::interval(Duration::from_secs(60)); loop { tick.tick().await; sweep_sessions.sweep_idle().await; + for id in sweep_ctx.http.sweep_idle() { + tracing::info!(session = %id, "scrapling session reaped (idle)"); + } } }); - tracing::info!("browser ready: browser::* sessions + console capture + pick"); + tracing::info!( + "browser ready: browser::* sessions + console capture + pick, browser::* parsing" + ); wait_for_shutdown_signal().await?; tracing::info!("browser shutting down"); sweep.abort(); + scrapling_ctx.http.close_all().await; sessions.stop_all().await; iii.shutdown_async().await; Ok(()) diff --git a/browser/src/manifest.rs b/browser/src/manifest.rs index f0bbc37fd..4d39daa54 100644 --- a/browser/src/manifest.rs +++ b/browser/src/manifest.rs @@ -17,7 +17,8 @@ pub fn build_manifest() -> ModuleManifest { version: env!("CARGO_PKG_VERSION").to_string(), description: "Interactive Chromium sessions on the iii bus. Navigate, act, read the page console, \ - pick elements." + pick elements. Also parses HTML natively without a browser \ + (browser::* — css/xpath/regex, element search, markdown)." .to_string(), default_config: WorkerConfig::default().to_json(), supported_targets: vec![env!("TARGET").to_string()], diff --git a/browser/src/scrapling/adaptive.rs b/browser/src/scrapling/adaptive.rs new file mode 100644 index 000000000..19403f93a --- /dev/null +++ b/browser/src/scrapling/adaptive.rs @@ -0,0 +1,685 @@ +//! Scrapling 0.4.9 Smart Element Tracking over the shared compatibility DOM. + +use std::collections::HashMap; +use std::hash::Hash; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{OnceLock, RwLock}; +use std::time::Duration; + +use rusqlite::{params, types::ValueRef, Connection}; +use serde::{Deserialize, Serialize}; +use serde_json::Map; + +use crate::scrapling::dom::{self, Doc, ElementRef}; +use crate::scrapling::query::{self, QueryResult}; + +const DEFAULT_PATH: &str = "./data/scrapling/elements.db"; +const MIN_SCORE: f64 = 40.0; + +// Delta from psl 2.1.180 to tld 0.13.2's frozen 2026-03-06 PSL. The source +// snapshot's SHA-256 is +// abf32ce9987d505b89765d76f35760543851235508f1f426b5b259a2062b5f68. +const FROZEN_ADDED_EXACT: &str = "1cooldns.com +auth.cognito-idp.eusc-de-east-1.on.amazonwebservices.eu +blob.core.usgovcloudapi.net +bumbleshrimp.com +com.kh +corespeed.app +ddnsguru.com +discourse.diy +drive-platform.com +drive-platform.io +dynuddns.com +dynuddns.net +dynuhosting.com +edu.kh +eu-west-1.convex.cloud +eu-west-1.convex.site +file.core.usgovcloudapi.net +file.core.windows.net +gov.kh +hue.vn +imagine.diy +intouch.email +kdns.fr +keenetic.io +keenetic.link +keenetic.name +keenetic.pro +kh +miren.app +miren.systems +ms.fun +ms.show +my.be +mybox.company +mybox.me +mybox.page +mysynology.net +net.kh +opik.net +org.kh +pivohosting.com +roxa.org +s3-website.dualstack.us-gov-east-1.amazonaws.com +s3-website.dualstack.us-gov-west-1.amazonaws.com +sandbox.deno.net +shiptoday.app +shiptoday.build +sol.site +spawnbase.app +spryt.net +transfer-webapp.ap-southeast-7.on.aws +transfer-webapp.mx-central-1.on.aws +us-east-1.convex.cloud +us-east-1.convex.site +usgovtrafficmanager.net +web.core.usgovcloudapi.net +web.core.windows.net +wiredbladehosting.com"; +const FROZEN_ADDED_WILDCARD_BASES: &str = "aa.crm.dev +ab.crm.dev +ac.crm.dev +ad.crm.dev +ae.crm.dev +af.crm.dev +begetcdn.cloud +ci.crm.dev +pa.crm.dev +pb.crm.dev +pc.crm.dev +pd.crm.dev +pe.crm.dev +pf.crm.dev"; +const FROZEN_REMOVED_EXACT: &[&str] = &["12chars.dev", "12chars.it", "12chars.pro", "mazeplay.com"]; + +static STORAGE_PATH: OnceLock<RwLock<PathBuf>> = OnceLock::new(); +// `u64::MAX` means oracle-compatible unbounded storage. +static MAX_BYTES: AtomicU64 = AtomicU64::new(u64::MAX); + +fn configured_path() -> &'static RwLock<PathBuf> { + STORAGE_PATH.get_or_init(|| RwLock::new(PathBuf::from(DEFAULT_PATH))) +} + +/// Set the process-wide adaptive database path, matching the standalone +/// worker's one-time `storage.configure(...)` boot setting. +pub fn configure(path: impl AsRef<Path>) -> Result<(), String> { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + *configured_path() + .write() + .map_err(|error| error.to_string())? = path.to_path_buf(); + Ok(()) +} + +/// Apply the live safe-mode storage ceiling. Compat passes `None` to retain +/// the standalone worker's unbounded behavior. +pub fn configure_quota(max_bytes: Option<u64>) { + MAX_BYTES.store(max_bytes.unwrap_or(u64::MAX), Ordering::Relaxed); +} + +fn storage_path() -> Result<PathBuf, String> { + configured_path() + .read() + .map(|path| path.clone()) + .map_err(|error| error.to_string()) +} + +pub fn css_query<'a>( + doc: &'a Doc, + scope: Option<ElementRef<'a>>, + selector: &str, + domain: Option<&str>, + identifier: &str, + auto_save: bool, +) -> Result<Vec<QueryResult<'a>>, String> { + let mut storage = Storage::open(domain)?; + if selector.contains(',') { + let selectors = cssselect::parse(selector) + .map_err(|error| format!("Invalid CSS selector '{selector}': {error}"))?; + let mut results = Vec::new(); + for parsed in selectors { + let direct = query::css_query(doc, scope, &parsed.canonical())?; + results.extend(storage.resolve(doc, direct, identifier, auto_save)?); + } + Ok(results) + } else { + let direct = query::css_query(doc, scope, selector)?; + storage.resolve(doc, direct, identifier, auto_save) + } +} + +pub fn xpath_query<'a>( + doc: &'a Doc, + scope: Option<ElementRef<'a>>, + selector: &str, + domain: Option<&str>, + identifier: &str, + auto_save: bool, +) -> Result<Vec<QueryResult<'a>>, String> { + let direct = crate::scrapling::xpath::xpath_query(doc, scope, selector)?; + Storage::open(domain)?.resolve(doc, direct, identifier, auto_save) +} + +struct Storage { + connection: Connection, + domain: String, +} + +impl Storage { + fn open(domain: Option<&str>) -> Result<Self, String> { + let path = storage_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + let connection = Connection::open(path).map_err(|error| error.to_string())?; + connection + .busy_timeout(Duration::from_secs(5)) + .map_err(|error| error.to_string())?; + connection + .pragma_update(None, "journal_mode", "WAL") + .map_err(|error| error.to_string())?; + connection + .execute_batch( + "CREATE TABLE IF NOT EXISTS storage (\n\ + id INTEGER PRIMARY KEY,\n\ + url TEXT,\n\ + identifier TEXT,\n\ + element_data TEXT,\n\ + UNIQUE (url, identifier)\n\ + );", + ) + .map_err(|error| error.to_string())?; + Ok(Self { + connection, + domain: base_url(domain), + }) + } + + fn resolve<'a>( + &mut self, + doc: &'a Doc, + direct: Vec<QueryResult<'a>>, + identifier: &str, + auto_save: bool, + ) -> Result<Vec<QueryResult<'a>>, String> { + if !direct.is_empty() { + if auto_save { + self.save(result_element(&direct[0]), identifier)?; + } + return Ok(direct); + } + + let Some(saved) = self.retrieve(identifier)? else { + return Ok(Vec::new()); + }; + let relocated = relocate(doc, &saved); + if auto_save { + if let Some(first) = relocated.first().copied() { + self.save(first, identifier)?; + } + } + Ok(relocated.into_iter().map(QueryResult::Element).collect()) + } + + fn save(&mut self, element: ElementRef<'_>, identifier: &str) -> Result<(), String> { + let bytes = serde_json::to_vec(&ElementData::from_element(element)) + .map_err(|error| error.to_string())?; + let transaction = self + .connection + .transaction() + .map_err(|error| error.to_string())?; + transaction + .execute( + "INSERT OR REPLACE INTO storage (url, identifier, element_data) VALUES (?, ?, ?)", + params![self.domain, identifier, bytes], + ) + .map_err(|error| error.to_string())?; + let max_bytes = MAX_BYTES.load(Ordering::Relaxed); + if max_bytes != u64::MAX { + let pages: u64 = transaction + .pragma_query_value(None, "page_count", |row| row.get(0)) + .map_err(|error| error.to_string())?; + let page_size: u64 = transaction + .pragma_query_value(None, "page_size", |row| row.get(0)) + .map_err(|error| error.to_string())?; + if pages.saturating_mul(page_size) > max_bytes { + return Err(format!( + "adaptive storage quota exceeded ({max_bytes} bytes); raise browser.scrapling.adaptive_max_bytes or disable adaptive mode" + )); + } + } + transaction.commit().map_err(|error| error.to_string()) + } + + fn retrieve(&self, identifier: &str) -> Result<Option<ElementData>, String> { + let mut statement = self + .connection + .prepare("SELECT element_data FROM storage WHERE url = ? AND identifier = ?") + .map_err(|error| error.to_string())?; + let mut rows = statement + .query(params![self.domain, identifier]) + .map_err(|error| error.to_string())?; + let Some(row) = rows.next().map_err(|error| error.to_string())? else { + return Ok(None); + }; + let value = row.get_ref(0).map_err(|error| error.to_string())?; + let bytes = match value { + ValueRef::Blob(bytes) | ValueRef::Text(bytes) => bytes, + _ => return Err("adaptive element_data is neither BLOB nor TEXT".to_string()), + }; + serde_json::from_slice(bytes) + .map(Some) + .map_err(|error| error.to_string()) + } +} + +fn result_element<'a>(result: &QueryResult<'a>) -> ElementRef<'a> { + match result { + QueryResult::Element(element) => *element, + QueryResult::Text { parent, .. } => *parent, + } +} + +fn base_url(domain: Option<&str>) -> String { + let Some(raw) = domain.filter(|value| !value.is_empty()) else { + return "default".to_string(); + }; + let lower = raw.to_lowercase(); + let parsed = url::Url::parse(&lower).or_else(|_| url::Url::parse(&format!("http://{lower}"))); + let Some(host) = parsed + .ok() + .and_then(|url| url.host_str().map(str::to_string)) + else { + return "default".to_string(); + }; + frozen_registrable_domain(&host).unwrap_or_else(|| "default".to_string()) +} + +fn frozen_registrable_domain(host: &str) -> Option<String> { + let exact = FROZEN_ADDED_EXACT + .lines() + .filter(|rule| host == *rule || host.ends_with(&format!(".{rule}"))) + .max_by_key(|rule| rule.len()); + let wildcard = FROZEN_ADDED_WILDCARD_BASES + .lines() + .filter_map(|base| { + let prefix = host.strip_suffix(&format!(".{base}"))?; + (!prefix.is_empty()).then_some((base, prefix)) + }) + .max_by_key(|(base, _)| base.len()); + + let frozen_suffix = match (exact, wildcard) { + (Some(rule), Some((base, prefix))) if base.len() + prefix.len() + 1 > rule.len() => { + let wildcard_label = prefix.rsplit('.').next()?; + format!("{wildcard_label}.{base}") + } + (Some(rule), _) => rule.to_string(), + (None, Some((base, prefix))) => { + let wildcard_label = prefix.rsplit('.').next()?; + format!("{wildcard_label}.{base}") + } + (None, None) => String::new(), + }; + if !frozen_suffix.is_empty() { + return registrable_for_suffix(host, &frozen_suffix); + } + + for rule in FROZEN_REMOVED_EXACT { + if host == *rule || host.ends_with(&format!(".{rule}")) { + return Some((*rule).to_string()); + } + } + if host == "goo" + || host.ends_with(".goo") + || host == "wolterskluwer" + || host.ends_with(".wolterskluwer") + { + return None; + } + if host.ends_with(".cns.joyent.com") { + return Some("joyent.com".to_string()); + } + if let Some(prefix) = host.strip_suffix(".kh") { + if !prefix.is_empty() { + let label = prefix.rsplit('.').next()?; + return Some(format!("{label}.kh")); + } + } + + let suffix = psl::suffix(host.as_bytes())?; + if !suffix.is_known() { + return None; + } + psl::domain_str(host) + .or_else(|| std::str::from_utf8(suffix.trim().as_bytes()).ok()) + .map(str::to_string) +} + +fn registrable_for_suffix(host: &str, suffix: &str) -> Option<String> { + if host == suffix { + return Some(suffix.to_string()); + } + let prefix = host.strip_suffix(&format!(".{suffix}"))?; + let label = prefix.rsplit('.').next()?; + Some(format!("{label}.{suffix}")) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ElementData { + tag: String, + attributes: Map<String, serde_json::Value>, + text: Option<String>, + path: Vec<String>, + #[serde(skip_serializing_if = "Option::is_none")] + parent_name: Option<String>, + #[serde(default, skip_serializing_if = "Map::is_empty")] + parent_attribs: Map<String, serde_json::Value>, + #[serde(skip_serializing_if = "Option::is_none")] + parent_text: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + siblings: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + children: Vec<String>, +} + +impl ElementData { + fn from_element(element: ElementRef<'_>) -> Self { + let attributes = element + .attrs() + .filter_map(|(name, value)| { + let value = value.trim(); + (!value.is_empty()).then(|| { + ( + name.to_string(), + serde_json::Value::String(value.to_string()), + ) + }) + }) + .collect(); + let raw_text = dom::leading_text(element); + let text = (!raw_text.is_empty()).then(|| raw_text.trim().to_string()); + let mut path = vec![element.name().to_string()]; + let mut current = element; + while let Some(parent) = dom::parent_element(current) { + path.push(parent.name().to_string()); + current = parent; + } + path.reverse(); + + let parent = dom::parent_element(element); + let parent_name = parent.map(|value| value.name().to_string()); + let parent_attribs = parent + .map(|value| dom::attrs_json(value)) + .unwrap_or_default(); + let parent_text = parent.and_then(|value| { + let raw = dom::leading_text(value); + (!raw.is_empty()).then(|| raw.trim().to_string()) + }); + let siblings = parent + .map(|value| { + dom::element_children(value) + .into_iter() + .filter(|child| child.id() != element.id()) + .map(|child| child.name().to_string()) + .collect() + }) + .unwrap_or_default(); + let children = dom::element_children(element) + .into_iter() + .map(|child| child.name().to_string()) + .collect(); + + Self { + tag: element.name().to_string(), + attributes, + text, + path, + parent_name, + parent_attribs, + parent_text, + siblings, + children, + } + } +} + +fn relocate<'a>(doc: &'a Doc, saved: &ElementData) -> Vec<ElementRef<'a>> { + let mut best = f64::NEG_INFINITY; + let mut matches = Vec::new(); + for element in dom::descendant_elements(doc.root()) { + let score = similarity(saved, &ElementData::from_element(element)); + if score > best { + best = score; + matches.clear(); + matches.push(element); + } else if score == best { + matches.push(element); + } + } + if best >= MIN_SCORE { + matches + } else { + Vec::new() + } +} + +fn similarity(original: &ElementData, candidate: &ElementData) -> f64 { + let mut score = f64::from(original.tag == candidate.tag); + let mut checks = 1usize; + + if let Some(text) = original.text.as_deref().filter(|value| !value.is_empty()) { + score += string_ratio(text, candidate.text.as_deref().unwrap_or("")); + checks += 1; + } + + score += map_ratio(&original.attributes, &candidate.attributes); + checks += 1; + for name in ["class", "id", "href", "src"] { + if let Some(value) = + string_attr(&original.attributes, name).filter(|value| !value.is_empty()) + { + score += string_ratio( + value, + string_attr(&candidate.attributes, name).unwrap_or(""), + ); + checks += 1; + } + } + + score += sequence_ratio(&original.path, &candidate.path); + checks += 1; + + if let Some(parent_name) = original + .parent_name + .as_deref() + .filter(|value| !value.is_empty()) + { + if let Some(candidate_parent) = candidate.parent_name.as_deref() { + score += string_ratio(parent_name, candidate_parent); + checks += 1; + score += map_ratio(&original.parent_attribs, &candidate.parent_attribs); + checks += 1; + if let Some(parent_text) = original + .parent_text + .as_deref() + .filter(|value| !value.is_empty()) + { + score += string_ratio(parent_text, candidate.parent_text.as_deref().unwrap_or("")); + checks += 1; + } + } + } + + if !original.siblings.is_empty() { + score += sequence_ratio(&original.siblings, &candidate.siblings); + checks += 1; + } + + round2((score / checks as f64) * 100.0) +} + +fn string_attr<'a>(map: &'a Map<String, serde_json::Value>, name: &str) -> Option<&'a str> { + map.get(name).and_then(serde_json::Value::as_str) +} + +fn map_ratio(left: &Map<String, serde_json::Value>, right: &Map<String, serde_json::Value>) -> f64 { + let left_keys: Vec<&str> = left.keys().map(String::as_str).collect(); + let right_keys: Vec<&str> = right.keys().map(String::as_str).collect(); + let left_values: Vec<&str> = left + .values() + .filter_map(serde_json::Value::as_str) + .collect(); + let right_values: Vec<&str> = right + .values() + .filter_map(serde_json::Value::as_str) + .collect(); + sequence_ratio(&left_keys, &right_keys) * 0.5 + + sequence_ratio(&left_values, &right_values) * 0.5 +} + +fn round2(value: f64) -> f64 { + format!("{value:.2}").parse().expect("formatted f64") +} + +fn string_ratio(left: &str, right: &str) -> f64 { + sequence_ratio( + &left.chars().collect::<Vec<_>>(), + &right.chars().collect::<Vec<_>>(), + ) +} + +fn sequence_ratio<T: Eq + Hash>(left: &[T], right: &[T]) -> f64 { + let total = left.len() + right.len(); + if total == 0 { + return 1.0; + } + let mut positions: HashMap<&T, Vec<usize>> = HashMap::new(); + for (index, item) in right.iter().enumerate() { + positions.entry(item).or_default().push(index); + } + if right.len() >= 200 { + let threshold = right.len() / 100 + 1; + positions.retain(|_, indexes| indexes.len() <= threshold); + } + + let mut queue = vec![(0, left.len(), 0, right.len())]; + let mut blocks = Vec::new(); + while let Some((left_start, left_end, right_start, right_end)) = queue.pop() { + let (i, j, size) = longest_match( + left, + right, + &positions, + left_start, + left_end, + right_start, + right_end, + ); + if size == 0 { + continue; + } + if left_start < i && right_start < j { + queue.push((left_start, i, right_start, j)); + } + if i + size < left_end && j + size < right_end { + queue.push((i + size, left_end, j + size, right_end)); + } + blocks.push((i, j, size)); + } + blocks.sort_unstable(); + let matches: usize = blocks.into_iter().map(|(_, _, size)| size).sum(); + 2.0 * matches as f64 / total as f64 +} + +#[allow(clippy::too_many_arguments)] +fn longest_match<T: Eq + Hash>( + left: &[T], + right: &[T], + positions: &HashMap<&T, Vec<usize>>, + left_start: usize, + left_end: usize, + right_start: usize, + right_end: usize, +) -> (usize, usize, usize) { + let (mut best_i, mut best_j, mut best_size) = (left_start, right_start, 0usize); + let mut previous = HashMap::new(); + for (i, item) in left.iter().enumerate().take(left_end).skip(left_start) { + let mut current = HashMap::new(); + if let Some(indexes) = positions.get(item) { + for &j in indexes { + if j < right_start { + continue; + } + if j >= right_end { + break; + } + let size = if j == 0 { + 1 + } else { + previous.get(&(j - 1)).copied().unwrap_or(0) + 1 + }; + current.insert(j, size); + if size > best_size { + (best_i, best_j, best_size) = (i + 1 - size, j + 1 - size, size); + } + } + } + previous = current; + } + while best_i > left_start && best_j > right_start && left[best_i - 1] == right[best_j - 1] { + best_i -= 1; + best_j -= 1; + best_size += 1; + } + while best_i + best_size < left_end + && best_j + best_size < right_end + && left[best_i + best_size] == right[best_j + best_size] + { + best_size += 1; + } + (best_i, best_j, best_size) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct UnlimitedOnDrop; + + impl Drop for UnlimitedOnDrop { + fn drop(&mut self) { + configure_quota(None); + } + } + + #[test] + fn sequence_matcher_compares_unicode_code_points() { + assert_eq!(string_ratio("é", "è"), 0.0); + } + + #[test] + fn sequence_matcher_does_not_extend_before_right_index_zero() { + assert_eq!(sequence_ratio(b"xx", b"x"), 2.0 / 3.0); + } + + #[test] + fn safe_quota_rolls_back_an_oversized_save() { + let path = std::env::temp_dir().join(format!( + "browser-adaptive-quota-{}.db", + uuid::Uuid::new_v4().simple() + )); + configure(&path).unwrap(); + configure_quota(Some(1)); + let _reset = UnlimitedOnDrop; + let doc = dom::parse("<p id=x>saved identity</p>"); + let error = css_query(&doc, None, "p", Some("example.com"), "p", true).unwrap_err(); + assert!(error.contains("adaptive storage quota exceeded"), "{error}"); + + for suffix in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(format!("{}{suffix}", path.display())); + } + } +} diff --git a/browser/src/scrapling/browserforge.rs b/browser/src/scrapling/browserforge.rs new file mode 100644 index 000000000..7968fa8a5 --- /dev/null +++ b/browser/src/scrapling/browserforge.rs @@ -0,0 +1,630 @@ +//! Frozen BrowserForge 1.2.4 header generator used by Scrapling 0.4.9. + +use std::collections::{HashMap, HashSet}; +use std::io::Read; +use std::sync::{Mutex, OnceLock}; + +use serde::Deserialize; +use serde_json::{Map, Value}; + +const MISSING: &str = "*MISSING_VALUE*"; +const INPUT_JSON: &str = include_str!("../../vendor/browserforge-1.2.4/input-network.json"); +const HEADER_JSON: &str = include_str!("../../vendor/browserforge-1.2.4/header-network.json"); + +const CHROME_ORDER: &[&str] = &[ + "Host", + "Connection", + "Content-Length", + "Cache-Control", + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + "Origin", + "Content-Type", + "Upgrade-Insecure-Requests", + "User-Agent", + "Accept", + "Sec-Fetch-Site", + "Sec-Fetch-Mode", + "Sec-Fetch-User", + "Sec-Fetch-Dest", + "Referer", + "Accept-Encoding", + "Accept-Language", + "Cookie", + ":method", + ":authority", + ":scheme", + ":path", + "content-length", + "cache-control", + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + "origin", + "content-type", + "upgrade-insecure-requests", + "user-agent", + "accept", + "sec-fetch-site", + "sec-fetch-mode", + "sec-fetch-user", + "sec-fetch-dest", + "referer", + "accept-encoding", + "accept-language", + "cookie", + "priority", +]; + +const FIREFOX_ORDER: &[&str] = &[ + "Host", + "User-Agent", + "Accept", + "Accept-Language", + "Accept-Encoding", + "Content-Type", + "Content-Length", + "Origin", + "Connection", + "Referer", + "Cookie", + "Upgrade-Insecure-Requests", + "Sec-Fetch-Dest", + "Sec-Fetch-Mode", + "Sec-Fetch-Site", + "Sec-Fetch-User", + "Priority", + ":method", + ":path", + ":authority", + ":scheme", + "user-agent", + "accept", + "accept-language", + "accept-encoding", + "content-type", + "content-length", + "origin", + "referer", + "cookie", + "upgrade-insecure-requests", + "sec-fetch-dest", + "sec-fetch-mode", + "sec-fetch-site", + "sec-fetch-user", + "priority", + "te", +]; + +#[derive(Deserialize)] +struct Network { + nodes: Vec<Node>, +} + +#[derive(Deserialize)] +struct Node { + name: String, + #[serde(rename = "parentNames")] + parent_names: Vec<String>, + #[serde(rename = "possibleValues")] + possible_values: Vec<String>, + #[serde(rename = "conditionalProbabilities")] + probabilities: Value, +} + +#[derive(Clone, Copy)] +enum Profile { + Http, + Browser, +} + +static INPUT: OnceLock<Result<Network, String>> = OnceLock::new(); +static HEADERS: OnceLock<Result<Network, String>> = OnceLock::new(); +static RNG: OnceLock<Mutex<PythonRandom>> = OnceLock::new(); +static HTTP_DEFAULT_UA: OnceLock<Result<String, String>> = OnceLock::new(); +static BROWSER_DEFAULTS: OnceLock<Result<(), String>> = OnceLock::new(); + +fn network( + slot: &'static OnceLock<Result<Network, String>>, + source: &'static str, +) -> Result<&'static Network, String> { + match slot.get_or_init(|| serde_json::from_str(source).map_err(|error| error.to_string())) { + Ok(network) => Ok(network), + Err(error) => Err(error.clone()), + } +} + +fn input_network() -> Result<&'static Network, String> { + network(&INPUT, INPUT_JSON) +} + +fn header_network() -> Result<&'static Network, String> { + network(&HEADERS, HEADER_JSON) +} + +fn rng() -> &'static Mutex<PythonRandom> { + RNG.get_or_init(|| Mutex::new(PythonRandom::from_os())) +} + +fn lock_rng() -> std::sync::MutexGuard<'static, PythonRandom> { + rng() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +pub fn default_user_agent() -> Result<String, String> { + HTTP_DEFAULT_UA + .get_or_init(|| { + generate(Profile::Http, &mut lock_rng())? + .into_iter() + .find(|(name, _)| name == "User-Agent") + .map(|(_, value)| value) + .ok_or_else(|| "BrowserForge produced no User-Agent".to_string()) + }) + .clone() +} + +pub fn generate_http_headers() -> Result<Vec<(String, String)>, String> { + // Importing Scrapling's static engine initializes this cached value before + // it generates per-request headers. The otherwise-unused draw is visible + // when deterministic RNG fixtures make multiple requests. + default_user_agent()?; + generate(Profile::Http, &mut lock_rng()) +} + +pub fn initialize_browser_defaults() -> Result<(), String> { + BROWSER_DEFAULTS + .get_or_init(|| { + // `_config_tools.py` imports fingerprints (which initializes the + // HTTP default) and then generates Chromium and Chrome defaults. + default_user_agent()?; + let mut random = lock_rng(); + generate(Profile::Browser, &mut random)?; + generate(Profile::Browser, &mut random)?; + Ok(()) + }) + .clone() +} + +pub(crate) fn randint(first: u32, last: u32) -> u32 { + debug_assert!(first <= last); + first + lock_rng().randbelow(last - first + 1) +} + +fn generate(profile: Profile, random: &mut PythonRandom) -> Result<Vec<(String, String)>, String> { + let input = input_network()?; + let mut constraints = HashMap::<String, Vec<String>>::new(); + constraints.insert("*DEVICE".to_string(), vec!["desktop".to_string()]); + constraints.insert( + "*OPERATING_SYSTEM".to_string(), + match profile { + Profile::Http => vec!["windows", "macos", "linux"], + Profile::Browser => vec!["linux"], + } + .into_iter() + .map(str::to_string) + .collect(), + ); + let browser_values = &input + .nodes + .iter() + .find(|node| node.name == "*BROWSER_HTTP") + .ok_or("BrowserForge input network has no *BROWSER_HTTP node")? + .possible_values; + let browser_http = match profile { + Profile::Browser => browser_values + .iter() + .filter(|value| browser_allowed(value, profile, "chrome")) + .cloned() + .collect(), + Profile::Http => ["chrome", "firefox", "edge"] + .into_iter() + .flat_map(|name| { + browser_values + .iter() + .filter(move |value| browser_allowed(value, profile, name)) + .cloned() + }) + .collect(), + }; + constraints.insert("*BROWSER_HTTP".to_string(), browser_http); + + let mut sample = Map::new(); + if !consistent_sample(input, &constraints, &mut sample, 0, random) { + return Err("No headers based on this input can be generated. Please relax or change some of the requirements you specified.".to_string()); + } + generate_unrestricted(header_network()?, &mut sample, random)?; + + let browser_http = sample + .get("*BROWSER_HTTP") + .and_then(Value::as_str) + .ok_or("BrowserForge sample has no *BROWSER_HTTP")?; + let add_sec_fetch = browser_http + .split_once('|') + .and_then(|(browser, _)| browser.split_once('/').map(|(name, _)| name)) + .map(|name| matches!(name, "chrome" | "firefox" | "edge")) + .ok_or("BrowserForge produced an invalid *BROWSER_HTTP")?; + sample.insert( + "accept-language".to_string(), + Value::String("en-US;q=1.0".to_string()), + ); + if add_sec_fetch { + for (name, value) in [ + ("sec-fetch-mode", "same-site"), + ("sec-fetch-dest", "navigate"), + ("sec-fetch-site", "?1"), + ("sec-fetch-user", "document"), + ] { + sample.insert(name.to_string(), Value::String(value.to_string())); + } + } + + let visible = sample + .into_iter() + .filter_map(|(name, value)| { + let value = value.as_str()?.to_string(); + (!(name.eq_ignore_ascii_case("connection") && value == "close") + && !name.starts_with('*') + && value != MISSING) + .then_some((name, value)) + }) + .collect::<HashMap<_, _>>(); + let user_agent = visible + .get("User-Agent") + .or_else(|| visible.get("user-agent")) + .map(String::as_str) + .ok_or("Failed to find User-Agent in generated response")?; + // BrowserForge checks Chrome before Edge. Their order tables are equal for + // the fields this frozen dataset emits, so preserve that quirk directly. + let order = if user_agent.contains("Firefox") || user_agent.contains("FxiOS") { + FIREFOX_ORDER + } else if user_agent.contains("Chrome") || user_agent.contains("CriOS") { + CHROME_ORDER + } else { + return Err("Failed to find browser in User-Agent".to_string()); + }; + let mut seen = HashSet::new(); + Ok(order + .iter() + .filter(|name| seen.insert(**name)) + .filter_map(|name| { + visible + .get(*name) + .map(|value| (pascalize(name), value.clone())) + }) + .collect()) +} + +fn browser_allowed(value: &str, profile: Profile, wanted: &str) -> bool { + let Some((browser, http)) = value.split_once('|') else { + return false; + }; + if http != "2" { + return false; + } + let Some((name, version)) = browser.split_once('/') else { + return false; + }; + let major = version + .split('.') + .next() + .and_then(|value| value.parse::<u32>().ok()); + if name != wanted { + return false; + } + match (profile, name, major) { + (Profile::Browser, "chrome", Some(148)) => true, + (Profile::Http, "chrome", Some(148)) => true, + (Profile::Http, "firefox", Some(version)) => version >= 142, + (Profile::Http, "edge", Some(version)) => version >= 140, + _ => false, + } +} + +fn consistent_sample( + network: &Network, + constraints: &HashMap<String, Vec<String>>, + sample: &mut Map<String, Value>, + depth: usize, + random: &mut PythonRandom, +) -> bool { + if depth == network.nodes.len() { + return true; + } + let node = &network.nodes[depth]; + let possibilities = constraints + .get(&node.name) + .map(Vec::as_slice) + .unwrap_or(&node.possible_values); + let mut banned = HashSet::new(); + loop { + let Some(value) = sample_restricted(node, sample, possibilities, &banned, random) else { + return false; + }; + sample.insert(node.name.clone(), Value::String(value.clone())); + if consistent_sample(network, constraints, sample, depth + 1, random) { + return true; + } + banned.insert(value); + sample.shift_remove(&node.name); + } +} + +fn generate_unrestricted( + network: &Network, + sample: &mut Map<String, Value>, + random: &mut PythonRandom, +) -> Result<(), String> { + for node in &network.nodes { + if sample.contains_key(&node.name) { + continue; + } + let probabilities = probability_table(node, sample) + .ok_or_else(|| format!("BrowserForge has no probabilities for {}", node.name))?; + let choices = probabilities.keys().map(String::as_str).collect::<Vec<_>>(); + let value = sample_value(&choices, probabilities, random) + .ok_or_else(|| format!("BrowserForge has no value for {}", node.name))?; + sample.insert(node.name.clone(), Value::String(value)); + } + Ok(()) +} + +fn sample_restricted( + node: &Node, + sample: &Map<String, Value>, + possibilities: &[String], + banned: &HashSet<String>, + random: &mut PythonRandom, +) -> Option<String> { + let probabilities = probability_table(node, sample)?; + let choices = possibilities + .iter() + .map(String::as_str) + .filter(|value| !banned.contains(*value) && probabilities.contains_key(*value)) + .collect::<Vec<_>>(); + sample_value(&choices, probabilities, random) +} + +fn probability_table<'a>( + node: &'a Node, + sample: &Map<String, Value>, +) -> Option<&'a Map<String, Value>> { + let mut current = &node.probabilities; + for parent in &node.parent_names { + let table = current.as_object()?; + let parent = sample.get(parent)?.as_str()?; + current = table + .get("deeper") + .and_then(Value::as_object) + .and_then(|deeper| deeper.get(parent)) + .or_else(|| table.get("skip"))?; + } + current.as_object() +} + +fn sample_value( + choices: &[&str], + probabilities: &Map<String, Value>, + random: &mut PythonRandom, +) -> Option<String> { + let first = choices.first()?; + let anchor = random.random(); + let mut cumulative = 0.0; + for choice in choices { + cumulative += probabilities.get(*choice)?.as_f64()?; + if cumulative > anchor { + return Some((*choice).to_string()); + } + } + Some((*first).to_string()) +} + +fn pascalize(name: &str) -> String { + if name.starts_with(':') || name.starts_with("sec-ch-ua") { + return name.to_string(); + } + if matches!(name, "dnt" | "rtt" | "ect") { + return name.to_ascii_uppercase(); + } + name.split('-') + .map(|part| { + let mut chars = part.chars(); + chars + .next() + .map(|first| { + first + .to_uppercase() + .chain(chars.flat_map(char::to_lowercase)) + .collect::<String>() + }) + .unwrap_or_default() + }) + .collect::<Vec<_>>() + .join("-") +} + +struct PythonRandom { + state: [u32; 624], + index: usize, +} + +impl PythonRandom { + fn from_os() -> Self { + let mut bytes = [0u8; 624 * 4]; + if std::fs::File::open("/dev/urandom") + .and_then(|mut file| file.read_exact(&mut bytes)) + .is_ok() + { + let mut key = [0u32; 624]; + for (word, bytes) in key.iter_mut().zip(bytes.chunks_exact(4)) { + *word = u32::from_ne_bytes(bytes.try_into().expect("four-byte chunk")); + } + return Self::from_key(&key); + } + let fallback = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u32; + Self::from_key(&[fallback, std::process::id()]) + } + + #[cfg(test)] + fn seeded(seed: u32) -> Self { + Self::from_key(&[seed]) + } + + fn from_key(key: &[u32]) -> Self { + let mut random = Self { + state: [0; 624], + index: 624, + }; + random.init_genrand(19_650_218); + let mut i = 1usize; + let mut j = 0usize; + for _ in 0..624.max(key.len()) { + let previous = random.state[i - 1]; + random.state[i] = (random.state[i] + ^ (previous ^ (previous >> 30)).wrapping_mul(1_664_525)) + .wrapping_add(key[j]) + .wrapping_add(j as u32); + i += 1; + j += 1; + if i >= 624 { + random.state[0] = random.state[623]; + i = 1; + } + if j >= key.len() { + j = 0; + } + } + for _ in 0..623 { + let previous = random.state[i - 1]; + random.state[i] = (random.state[i] + ^ (previous ^ (previous >> 30)).wrapping_mul(1_566_083_941)) + .wrapping_sub(i as u32); + i += 1; + if i >= 624 { + random.state[0] = random.state[623]; + i = 1; + } + } + random.state[0] = 0x8000_0000; + random.index = 624; + random + } + + fn init_genrand(&mut self, seed: u32) { + self.state[0] = seed; + for index in 1..624 { + let previous = self.state[index - 1]; + self.state[index] = 1_812_433_253u32 + .wrapping_mul(previous ^ (previous >> 30)) + .wrapping_add(index as u32); + } + } + + fn word(&mut self) -> u32 { + if self.index >= 624 { + for index in 0..624 { + let y = (self.state[index] & 0x8000_0000) + | (self.state[(index + 1) % 624] & 0x7fff_ffff); + self.state[index] = self.state[(index + 397) % 624] + ^ (y >> 1) + ^ if y & 1 == 0 { 0 } else { 0x9908_b0df }; + } + self.index = 0; + } + let mut value = self.state[self.index]; + self.index += 1; + value ^= value >> 11; + value ^= (value << 7) & 0x9d2c_5680; + value ^= (value << 15) & 0xefc6_0000; + value ^= value >> 18; + value + } + + fn random(&mut self) -> f64 { + let high = (self.word() >> 5) as u64; + let low = (self.word() >> 6) as u64; + ((high << 26) | low) as f64 / 9_007_199_254_740_992.0 + } + + fn getrandbits(&mut self, bits: u32) -> u32 { + debug_assert!((1..=32).contains(&bits)); + self.word() >> (32 - bits) + } + + fn randbelow(&mut self, upper: u32) -> u32 { + debug_assert!(upper > 0); + let bits = 32 - upper.leading_zeros(); + loop { + let value = self.getrandbits(bits); + if value < upper { + return value; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cpython_seed_zero_and_frozen_header_sample_match() { + let mut random = PythonRandom::seeded(0); + assert_eq!(random.random(), 0.8444218515250481); + let headers = generate(Profile::Http, &mut PythonRandom::seeded(0)).unwrap(); + assert_eq!( + headers, + vec![ + ("sec-ch-ua".into(), "\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"".into()), + ("sec-ch-ua-mobile".into(), "?0".into()), + ("sec-ch-ua-platform".into(), "\"macOS\"".into()), + ("Upgrade-Insecure-Requests".into(), "1".into()), + ("User-Agent".into(), "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36".into()), + ("Accept".into(), "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7".into()), + ("Sec-Fetch-Site".into(), "?1".into()), + ("Sec-Fetch-Mode".into(), "same-site".into()), + ("Sec-Fetch-User".into(), "document".into()), + ("Sec-Fetch-Dest".into(), "navigate".into()), + ("Accept-Encoding".into(), "gzip, deflate, br, zstd".into()), + ("Accept-Language".into(), "en-US;q=1.0".into()), + ] + ); + } + + #[test] + fn cpython_randint_click_sequence_matches() { + let mut random = PythonRandom::seeded(0); + assert_eq!(26 + random.randbelow(3), 27); + assert_eq!(25 + random.randbelow(3), 26); + assert_eq!(100 + random.randbelow(101), 105); + } + + #[test] + fn browser_profile_matches_frozen_linux_chromium() { + let headers = generate(Profile::Browser, &mut PythonRandom::seeded(0)).unwrap(); + assert_eq!( + headers + .iter() + .find(|(name, _)| name == "User-Agent") + .map(|(_, value)| value.as_str()), + Some("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36") + ); + } + + #[test] + fn ten_thousand_seed_header_corpus_matches_browserforge() { + let mut hash = 14_695_981_039_346_656_037u64; + for seed in 0..10_000 { + for (name, value) in generate(Profile::Http, &mut PythonRandom::seeded(seed)).unwrap() { + for byte in name.bytes().chain([0]).chain(value.bytes()).chain([255]) { + hash = (hash ^ u64::from(byte)).wrapping_mul(1_099_511_628_211); + } + } + } + assert_eq!(hash, 0xa96f_eabb_879e_a69c); + } +} diff --git a/browser/src/scrapling/cdp.rs b/browser/src/scrapling/cdp.rs new file mode 100644 index 000000000..76ea832b1 --- /dev/null +++ b/browser/src/scrapling/cdp.rs @@ -0,0 +1,842 @@ +//! Private raw Chrome DevTools Protocol connection foundation. +//! +//! Chromium's `--remote-debugging-pipe` protocol is UTF-8 JSON terminated by +//! a NUL byte. The browser reads commands from descriptor 3 and writes events +//! and responses to descriptor 4. Launch code owns creating/inheriting those +//! descriptors; this module owns framing, routing, cancellation and teardown. + +use std::collections::HashMap; +use std::fmt; +use std::future::Future; +use std::io::{Read, Write}; +use std::pin::Pin; +use std::process::{Child, Command, ExitStatus}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, Weak}; +use std::task::{Context, Poll}; +use std::thread::{self, JoinHandle}; + +use futures::{SinkExt, StreamExt}; +use serde_json::{Map, Value}; +use tokio::sync::{broadcast, mpsc, oneshot, watch}; +use tokio_tungstenite::tungstenite::Message; + +pub const REMOTE_DEBUGGING_PIPE_ARG: &str = "--remote-debugging-pipe"; +pub const MAX_CDP_MESSAGE_BYTES: usize = 64 * 1024 * 1024; + +#[derive(Clone, Debug, PartialEq)] +pub enum CdpError { + Closed, + Transport(String), + InvalidMessage(String), + Protocol { + code: i64, + message: String, + data: Option<Value>, + }, + UnsupportedTransport(String), +} + +impl fmt::Display for CdpError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Closed => f.write_str("CDP connection is closed"), + Self::Transport(message) | Self::InvalidMessage(message) => f.write_str(message), + Self::Protocol { code, message, .. } => write!(f, "CDP error {code}: {message}"), + Self::UnsupportedTransport(message) => f.write_str(message), + } + } +} + +impl std::error::Error for CdpError {} + +#[derive(Clone, Debug, PartialEq)] +pub struct CdpEvent { + pub session_id: Option<String>, + pub method: String, + pub params: Value, +} + +#[derive(Debug)] +pub enum EventError { + Closed, +} + +impl fmt::Display for EventError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Closed => f.write_str("CDP event stream is closed"), + } + } +} + +impl std::error::Error for EventError {} + +pub struct EventReceiver { + receiver: broadcast::Receiver<CdpEvent>, + disconnected: watch::Receiver<bool>, + session_id: Option<String>, + /// Deliver events from EVERY session, not just `session_id`. Needed by + /// the child-target router: auto-attached OOPIF/worker targets announce + /// themselves tagged with their PARENT page's sessionId, which a + /// root-scoped receiver would filter out. + any_session: bool, +} + +impl EventReceiver { + pub async fn recv(&mut self) -> Result<CdpEvent, EventError> { + loop { + if *self.disconnected.borrow() { + return Err(EventError::Closed); + } + tokio::select! { + changed = self.disconnected.changed() => { + if changed.is_err() || *self.disconnected.borrow() { + return Err(EventError::Closed); + } + } + event = self.receiver.recv() => match event { + Ok(event) if self.any_session || event.session_id == self.session_id => { + return Ok(event); + } + Ok(_) => {} + Err(broadcast::error::RecvError::Closed) => return Err(EventError::Closed), + // Lagged is recoverable: the receiver skipped old events + // and can keep going. Surfacing it as an error made every + // consumer treat a busy page as a dead connection (the + // child-target router died forever; waits hard-failed + // pages that loaded fine). A waiter that missed its event + // falls back to its own deadline instead. + Err(broadcast::error::RecvError::Lagged(_)) => {} + } + } + } + } +} + +struct Pending { + session_id: Option<String>, + sender: oneshot::Sender<Result<Value, CdpError>>, +} + +enum WriterCommand { + Frame(Vec<u8>), + Shutdown, +} + +#[derive(Default)] +struct ProcessState { + child: Option<Child>, + status: Option<ExitStatus>, +} + +struct Inner { + next_id: AtomicU64, + closed: AtomicBool, + pending: Mutex<HashMap<u64, Pending>>, + events: broadcast::Sender<CdpEvent>, + disconnected: watch::Sender<bool>, + writer_tx: mpsc::UnboundedSender<WriterCommand>, + writer_thread: Mutex<Option<JoinHandle<()>>>, + reader_thread: Mutex<Option<JoinHandle<()>>>, + websocket_task: Mutex<Option<tokio::task::JoinHandle<()>>>, + process: Mutex<ProcessState>, +} + +impl Inner { + fn terminate(&self, reason: CdpError) { + if self.closed.swap(true, Ordering::AcqRel) { + return; + } + let callbacks = std::mem::take(&mut *lock(&self.pending)); + for (_, pending) in callbacks { + let _ = pending.sender.send(Err(reason.clone())); + } + let _ = self.writer_tx.send(WriterCommand::Shutdown); + let _ = self.disconnected.send(true); + self.terminate_process(); + } + + fn terminate_process(&self) { + let mut process = lock(&self.process); + let Some(mut child) = process.child.take() else { + return; + }; + let status = match child.try_wait() { + Ok(Some(status)) => Some(status), + Ok(None) => child.kill().and_then(|()| child.wait()).ok(), + Err(_) => child.kill().and_then(|()| child.wait()).ok(), + }; + process.status = status; + } + + fn route(&self, message: Value) -> Result<(), CdpError> { + let object = message + .as_object() + .ok_or_else(|| CdpError::InvalidMessage("CDP message is not an object".to_string()))?; + let session_id = object + .get("sessionId") + .and_then(Value::as_str) + .map(str::to_string); + + if object.get("id").and_then(Value::as_i64) == Some(-9999) { + return Ok(()); + } + if object.contains_key("id") { + let id = object.get("id").and_then(Value::as_u64).ok_or_else(|| { + CdpError::InvalidMessage("CDP response has an invalid id".to_string()) + })?; + let mut pending = lock(&self.pending); + // Command ids are client-global, so the id alone identifies the + // command. Chrome emits some id-bearing ERROR frames untagged + // (e.g. "Session with given id not found") — dropping those on a + // sessionId mismatch orphans the await forever. Only a frame + // tagged with a DIFFERENT session is rejected. + let matches_session = pending + .get(&id) + .is_some_and(|callback| session_id.is_none() || callback.session_id == session_id); + if !matches_session { + return Ok(()); + } + let callback = pending.remove(&id).expect("checked pending command"); + let result = if let Some(error) = object.get("error").and_then(Value::as_object) { + Err(CdpError::Protocol { + code: error.get("code").and_then(Value::as_i64).unwrap_or(0), + message: error + .get("message") + .and_then(Value::as_str) + .unwrap_or("Unknown protocol error") + .to_string(), + data: error.get("data").cloned(), + }) + } else { + Ok(object.get("result").cloned().unwrap_or(Value::Null)) + }; + let _ = callback.sender.send(result); + return Ok(()); + } + + let method = object + .get("method") + .and_then(Value::as_str) + .ok_or_else(|| CdpError::InvalidMessage("CDP event has no method".to_string()))?; + let _ = self.events.send(CdpEvent { + session_id, + method: method.to_string(), + params: object.get("params").cloned().unwrap_or(Value::Null), + }); + Ok(()) + } +} + +impl Drop for Inner { + fn drop(&mut self) { + self.closed.store(true, Ordering::Release); + let _ = self.writer_tx.send(WriterCommand::Shutdown); + let _ = self.disconnected.send(true); + if let Some(task) = lock(&self.websocket_task).take() { + task.abort(); + } + self.terminate_process(); + } +} + +#[derive(Clone)] +pub struct CdpClient { + inner: Arc<Inner>, +} + +impl fmt::Debug for CdpClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CdpClient") + .field("closed", &self.is_closed()) + .finish_non_exhaustive() + } +} + +impl CdpClient { + pub fn from_pipe<R, W>(reader: R, writer: W, child: Option<Child>) -> Result<Self, CdpError> + where + R: Read + Send + 'static, + W: Write + Send + 'static, + { + let (inner, writer_rx) = new_inner(child); + + let writer_inner = Arc::downgrade(&inner); + let writer_thread = thread::Builder::new() + .name("scrapling-cdp-writer".to_string()) + .spawn(move || writer_loop(writer, writer_rx, writer_inner)) + .map_err(|error| CdpError::Transport(error.to_string()))?; + *lock(&inner.writer_thread) = Some(writer_thread); + + let reader_inner = Arc::downgrade(&inner); + let reader_thread = match thread::Builder::new() + .name("scrapling-cdp-reader".to_string()) + .spawn(move || reader_loop(reader, reader_inner)) + { + Ok(thread) => thread, + Err(error) => { + inner.terminate(CdpError::Transport(error.to_string())); + if let Some(thread) = lock(&inner.writer_thread).take() { + let _ = thread.join(); + } + return Err(CdpError::Transport(error.to_string())); + } + }; + *lock(&inner.reader_thread) = Some(reader_thread); + Ok(Self { inner }) + } + + #[cfg(unix)] + /// Spawn one Chrome process with CDP mapped to fd3/fd4. + /// + /// `CommandExt::pre_exec` has no removal API, so `command` is single-use + /// after this call, including when spawning fails. + pub fn launch_pipe(command: &mut Command) -> Result<Self, CdpError> { + use std::fs::File; + use std::os::fd::AsRawFd; + use std::os::unix::process::CommandExt; + + let (child_commands, parent_commands) = pipe_cloexec()?; + let (parent_events, child_events) = pipe_cloexec()?; + let child_commands = dup_cloexec(child_commands.as_raw_fd())?; + let child_events = dup_cloexec(child_events.as_raw_fd())?; + let command_fd = child_commands.as_raw_fd(); + let event_fd = child_events.as_raw_fd(); + + if !command + .get_args() + .any(|argument| argument == REMOTE_DEBUGGING_PIPE_ARG) + { + command.arg(REMOTE_DEBUGGING_PIPE_ARG); + } + // SAFETY: the closure calls only async-signal-safe libc functions and + // captures raw integers. Both sources are duplicated above fd 4, so + // mapping one cannot clobber the other. + unsafe { + command.pre_exec(move || { + if libc::dup2(command_fd, 3) == -1 || libc::dup2(event_fd, 4) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + + let child = command + .spawn() + .map_err(|error| CdpError::Transport(format!("launching Chrome: {error}")))?; + drop(child_commands); + drop(child_events); + let reader = File::from(parent_events); + let writer = File::from(parent_commands); + Self::from_pipe(reader, writer, Some(child)) + } + + #[cfg(not(unix))] + pub fn launch_pipe(_command: &mut Command) -> Result<Self, CdpError> { + Err(CdpError::UnsupportedTransport( + "--remote-debugging-pipe launch is supported only on Unix".to_string(), + )) + } + + pub async fn connect_websocket_url(url: &str) -> Result<Self, CdpError> { + let scheme = url.split(':').next().unwrap_or_default(); + if !matches!(scheme, "ws" | "wss") { + return Err(CdpError::UnsupportedTransport(format!( + "cdp_url must use ws:// or wss://, got '{url}'" + ))); + } + let (socket, _) = tokio_tungstenite::connect_async(url) + .await + .map_err(|error| CdpError::Transport(format!("connecting cdp_url '{url}': {error}")))?; + let (inner, writer_rx) = new_inner(None); + let task_inner = Arc::downgrade(&inner); + let task = tokio::spawn(websocket_loop(socket, writer_rx, task_inner)); + *lock(&inner.websocket_task) = Some(task); + Ok(Self { inner }) + } + + pub fn session(&self, session_id: impl Into<String>) -> CdpSession { + CdpSession { + client: self.clone(), + session_id: session_id.into(), + } + } + + pub fn send(&self, method: &str, params: Value) -> Result<CdpCommand, CdpError> { + self.send_to(None, method, params) + } + + fn send_to( + &self, + session_id: Option<String>, + method: &str, + params: Value, + ) -> Result<CdpCommand, CdpError> { + if self.is_closed() { + return Err(CdpError::Closed); + } + if method.is_empty() { + return Err(CdpError::InvalidMessage( + "CDP method cannot be empty".to_string(), + )); + } + let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed) + 1; + let mut message = Map::new(); + message.insert("id".to_string(), Value::from(id)); + message.insert("method".to_string(), Value::from(method)); + message.insert("params".to_string(), params); + if let Some(value) = &session_id { + message.insert("sessionId".to_string(), Value::from(value.clone())); + } + let mut frame = serde_json::to_vec(&Value::Object(message)) + .map_err(|error| CdpError::InvalidMessage(error.to_string()))?; + if frame.len() > MAX_CDP_MESSAGE_BYTES { + return Err(CdpError::InvalidMessage(format!( + "CDP message exceeds {MAX_CDP_MESSAGE_BYTES} bytes" + ))); + } + frame.push(0); + + let (sender, receiver) = oneshot::channel(); + lock(&self.inner.pending).insert(id, Pending { session_id, sender }); + if self + .inner + .writer_tx + .send(WriterCommand::Frame(frame)) + .is_err() + { + lock(&self.inner.pending).remove(&id); + self.inner.terminate(CdpError::Closed); + return Err(CdpError::Closed); + } + // Close the TOCTOU with terminate(): it may have drained `pending` + // between the is_closed check above and the insert, leaving this + // entry to never complete. Re-check after the insert — whichever side + // runs second cleans up. + if self.is_closed() { + lock(&self.inner.pending).remove(&id); + return Err(CdpError::Closed); + } + Ok(CdpCommand { + id, + receiver, + inner: Arc::downgrade(&self.inner), + completed: false, + deadline: None, + }) + } + + pub fn subscribe(&self) -> EventReceiver { + EventReceiver { + receiver: self.inner.events.subscribe(), + disconnected: self.inner.disconnected.subscribe(), + session_id: None, + any_session: false, + } + } + + /// Subscribe to events from every session (see `EventReceiver::any_session`). + pub fn subscribe_any(&self) -> EventReceiver { + EventReceiver { + receiver: self.inner.events.subscribe(), + disconnected: self.inner.disconnected.subscribe(), + session_id: None, + any_session: true, + } + } + + pub fn is_closed(&self) -> bool { + self.inner.closed.load(Ordering::Acquire) + } + + pub fn close(&self) -> Result<(), CdpError> { + self.inner.terminate(CdpError::Closed); + join_thread(&self.inner.writer_thread)?; + join_thread(&self.inner.reader_thread)?; + Ok(()) + } + + pub fn process_status(&self) -> Option<ExitStatus> { + lock(&self.inner.process).status + } + + #[cfg(test)] + pub fn pending_count(&self) -> usize { + lock(&self.inner.pending).len() + } +} + +#[derive(Clone, Debug)] +pub struct CdpSession { + client: CdpClient, + session_id: String, +} + +impl CdpSession { + pub fn id(&self) -> &str { + &self.session_id + } + + pub fn send(&self, method: &str, params: Value) -> Result<CdpCommand, CdpError> { + self.client + .send_to(Some(self.session_id.clone()), method, params) + } + + pub fn subscribe(&self) -> EventReceiver { + EventReceiver { + receiver: self.client.inner.events.subscribe(), + disconnected: self.client.inner.disconnected.subscribe(), + session_id: Some(self.session_id.clone()), + any_session: false, + } + } +} + +/// Hard ceiling on any single CDP command round-trip. A blocked renderer +/// main thread (`while(1){}` with the hang monitor disabled, a wedged +/// browser process) simply never answers, and an unbounded await here is +/// how one-shot fetches hang and session actors wedge forever. Generous on +/// purpose: real commands answer in milliseconds and even a tarpit +/// navigation is bounded by Chrome's own ~5-minute network timeout region. +const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180); + +pub struct CdpCommand { + id: u64, + receiver: oneshot::Receiver<Result<Value, CdpError>>, + inner: Weak<Inner>, + completed: bool, + deadline: Option<Pin<Box<tokio::time::Sleep>>>, +} + +impl Future for CdpCommand { + type Output = Result<Value, CdpError>; + + fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> { + match Pin::new(&mut self.receiver).poll(context) { + Poll::Ready(Ok(result)) => { + self.completed = true; + Poll::Ready(result) + } + Poll::Ready(Err(_)) => { + self.completed = true; + Poll::Ready(Err(CdpError::Closed)) + } + Poll::Pending => { + let deadline = self + .deadline + .get_or_insert_with(|| Box::pin(tokio::time::sleep(COMMAND_TIMEOUT))); + match deadline.as_mut().poll(context) { + Poll::Ready(()) => { + self.completed = true; + if let Some(inner) = self.inner.upgrade() { + lock(&inner.pending).remove(&self.id); + } + Poll::Ready(Err(CdpError::Transport(format!( + "CDP command timed out after {}s (renderer or browser unresponsive)", + COMMAND_TIMEOUT.as_secs() + )))) + } + Poll::Pending => Poll::Pending, + } + } + } + } +} + +impl Drop for CdpCommand { + fn drop(&mut self) { + if self.completed { + return; + } + if let Some(inner) = self.inner.upgrade() { + lock(&inner.pending).remove(&self.id); + } + } +} + +fn new_inner(child: Option<Child>) -> (Arc<Inner>, mpsc::UnboundedReceiver<WriterCommand>) { + let (writer_tx, writer_rx) = mpsc::unbounded_channel(); + // Sized for bursty pages: every session's events share this channel, and + // an overflow only costs the laggard skipped events (recv treats Lagged + // as recoverable), but skipping is still worth avoiding. + let (events, _) = broadcast::channel(4096); + let (disconnected, _) = watch::channel(false); + let inner = Arc::new(Inner { + next_id: AtomicU64::new(0), + closed: AtomicBool::new(false), + pending: Mutex::new(HashMap::new()), + events, + disconnected, + writer_tx, + writer_thread: Mutex::new(None), + reader_thread: Mutex::new(None), + websocket_task: Mutex::new(None), + process: Mutex::new(ProcessState { + child, + status: None, + }), + }); + (inner, writer_rx) +} + +fn writer_loop<W: Write>( + mut writer: W, + mut commands: mpsc::UnboundedReceiver<WriterCommand>, + inner: Weak<Inner>, +) { + while let Some(command) = commands.blocking_recv() { + match command { + WriterCommand::Frame(frame) => { + if let Err(error) = writer.write_all(&frame).and_then(|()| writer.flush()) { + if let Some(inner) = inner.upgrade() { + inner.terminate(CdpError::Transport(format!("writing CDP pipe: {error}"))); + } + break; + } + } + WriterCommand::Shutdown => break, + } + } +} + +async fn websocket_loop<S>( + socket: tokio_tungstenite::WebSocketStream<S>, + mut commands: mpsc::UnboundedReceiver<WriterCommand>, + inner: Weak<Inner>, +) where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + let (mut writer, mut reader) = socket.split(); + loop { + tokio::select! { + command = commands.recv() => match command { + Some(WriterCommand::Frame(mut frame)) => { + if frame.last() == Some(&0) { + frame.pop(); + } + let text = match String::from_utf8(frame) { + Ok(text) => text, + Err(error) => { + terminate_weak(&inner, CdpError::InvalidMessage(error.to_string())); + break; + } + }; + if let Err(error) = writer.send(Message::Text(text.into())).await { + terminate_weak(&inner, CdpError::Transport(format!("writing cdp_url: {error}"))); + break; + } + } + Some(WriterCommand::Shutdown) | None => { + let _ = writer.send(Message::Close(None)).await; + let _ = writer.close().await; + break; + } + }, + incoming = reader.next() => match incoming { + Some(Ok(Message::Text(text))) => { + if !route_websocket_message(&inner, text.as_bytes()) { + break; + } + } + Some(Ok(Message::Binary(bytes))) => { + if !route_websocket_message(&inner, &bytes) { + break; + } + } + Some(Ok(Message::Ping(bytes))) => { + if let Err(error) = writer.send(Message::Pong(bytes)).await { + terminate_weak(&inner, CdpError::Transport(format!("writing cdp_url pong: {error}"))); + break; + } + } + Some(Ok(Message::Pong(_))) => {} + Some(Ok(Message::Close(_))) | None => { + terminate_weak(&inner, CdpError::Closed); + break; + } + Some(Ok(Message::Frame(_))) => {} + Some(Err(error)) => { + terminate_weak(&inner, CdpError::Transport(format!("reading cdp_url: {error}"))); + break; + } + } + } + } +} + +fn route_websocket_message(inner: &Weak<Inner>, bytes: &[u8]) -> bool { + let message = match serde_json::from_slice(bytes) { + Ok(message) => message, + Err(error) => { + terminate_weak( + inner, + CdpError::InvalidMessage(format!("invalid CDP JSON: {error}")), + ); + return false; + } + }; + let Some(inner) = inner.upgrade() else { + return false; + }; + match inner.route(message) { + Ok(()) => true, + Err(error) => { + inner.terminate(error); + false + } + } +} + +fn terminate_weak(inner: &Weak<Inner>, error: CdpError) { + if let Some(inner) = inner.upgrade() { + inner.terminate(error); + } +} + +fn reader_loop<R: Read>(reader: R, inner: Weak<Inner>) { + let mut frames = NulFrames::new(reader); + loop { + let result = match frames.next() { + Ok(Some(frame)) => serde_json::from_slice(&frame) + .map_err(|error| CdpError::InvalidMessage(format!("invalid CDP JSON: {error}"))), + Ok(None) => { + if let Some(inner) = inner.upgrade() { + inner.terminate(CdpError::Closed); + } + return; + } + Err(error) => Err(error), + }; + let Some(inner) = inner.upgrade() else { + return; + }; + match result.and_then(|message| inner.route(message)) { + Ok(()) => {} + Err(error) => { + inner.terminate(error); + return; + } + } + } +} + +struct NulFrames<R> { + reader: R, + pending: Vec<u8>, +} + +impl<R: Read> NulFrames<R> { + fn new(reader: R) -> Self { + Self { + reader, + pending: Vec::new(), + } + } + + fn next(&mut self) -> Result<Option<Vec<u8>>, CdpError> { + loop { + if let Some(end) = self.pending.iter().position(|byte| *byte == 0) { + if end > MAX_CDP_MESSAGE_BYTES { + return Err(CdpError::InvalidMessage(format!( + "CDP message exceeds {MAX_CDP_MESSAGE_BYTES} bytes" + ))); + } + let mut remainder = self.pending.split_off(end + 1); + std::mem::swap(&mut remainder, &mut self.pending); + remainder.pop(); + return Ok(Some(remainder)); + } + if self.pending.len() > MAX_CDP_MESSAGE_BYTES { + return Err(CdpError::InvalidMessage(format!( + "CDP message exceeds {MAX_CDP_MESSAGE_BYTES} bytes" + ))); + } + let mut buffer = [0; 8192]; + match self.reader.read(&mut buffer) { + Ok(0) if self.pending.is_empty() => return Ok(None), + Ok(0) => { + return Err(CdpError::InvalidMessage( + "CDP pipe closed during a frame".to_string(), + )); + } + Ok(count) => self.pending.extend_from_slice(&buffer[..count]), + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(error) => { + return Err(CdpError::Transport(format!("reading CDP pipe: {error}"))); + } + } + } + } +} + +fn join_thread(slot: &Mutex<Option<JoinHandle<()>>>) -> Result<(), CdpError> { + if let Some(thread) = lock(slot).take() { + thread + .join() + .map_err(|_| CdpError::Transport("CDP transport thread panicked".to_string()))?; + } + Ok(()) +} + +#[cfg(unix)] +fn pipe_cloexec() -> Result<(std::os::fd::OwnedFd, std::os::fd::OwnedFd), CdpError> { + use std::os::fd::FromRawFd; + + let mut descriptors = [-1; 2]; + #[cfg(any(target_os = "linux", target_os = "android"))] + // SAFETY: `descriptors` points to space for exactly two file descriptors. + let result = unsafe { libc::pipe2(descriptors.as_mut_ptr(), libc::O_CLOEXEC) }; + #[cfg(not(any(target_os = "linux", target_os = "android")))] + // SAFETY: `descriptors` points to space for exactly two file descriptors. + let result = unsafe { libc::pipe(descriptors.as_mut_ptr()) }; + if result == -1 { + return Err(CdpError::Transport(format!( + "creating Chrome CDP pipe: {}", + std::io::Error::last_os_error() + ))); + } + // SAFETY: successful `pipe2` returned two new, uniquely owned descriptors. + let pipes = unsafe { + ( + std::os::fd::OwnedFd::from_raw_fd(descriptors[0]), + std::os::fd::OwnedFd::from_raw_fd(descriptors[1]), + ) + }; + #[cfg(not(any(target_os = "linux", target_os = "android")))] + for descriptor in [&pipes.0, &pipes.1] { + use std::os::fd::AsRawFd; + + // SAFETY: the descriptor is owned and live for this call. + if unsafe { libc::fcntl(descriptor.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) } == -1 { + return Err(CdpError::Transport(format!( + "setting close-on-exec on Chrome CDP pipe: {}", + std::io::Error::last_os_error() + ))); + } + } + Ok(pipes) +} + +#[cfg(unix)] +fn dup_cloexec(descriptor: std::os::fd::RawFd) -> Result<std::os::fd::OwnedFd, CdpError> { + use std::os::fd::FromRawFd; + + // Keep both pre-exec source descriptors above Chrome's fixed fd3/fd4. + // SAFETY: `descriptor` is live for this call and `fcntl` creates a new fd. + let duplicated = unsafe { libc::fcntl(descriptor, libc::F_DUPFD_CLOEXEC, 5) }; + if duplicated == -1 { + return Err(CdpError::Transport(format!( + "duplicating Chrome CDP pipe: {}", + std::io::Error::last_os_error() + ))); + } + // SAFETY: successful `fcntl(F_DUPFD_CLOEXEC)` returned a new owned fd. + Ok(unsafe { std::os::fd::OwnedFd::from_raw_fd(duplicated) }) +} + +fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} diff --git a/browser/src/scrapling/crawl.rs b/browser/src/scrapling/crawl.rs new file mode 100644 index 000000000..25a908527 --- /dev/null +++ b/browser/src/scrapling/crawl.rs @@ -0,0 +1,896 @@ +//! `browser::crawl` — breadth-first crawl from one or more start +//! URLs, extracting per page and streaming the results (crawl.py:73-190). +//! +//! The frontier walk is parameterised over the fetch step, so the whole +//! algorithm — ordering, dedup, the depth and page caps, per-page error +//! isolation — is testable against a canned link graph with no network and no +//! browser. Only the closure passed in at registration does I/O. + +use std::collections::{HashSet, VecDeque}; +use std::time::Duration; + +use futures::stream::{FuturesUnordered, StreamExt}; +use futures::FutureExt; +use serde_json::{json, Value}; + +use crate::config::SecurityMode; +use crate::scrapling::dom; +use crate::scrapling::page::{serialize_page, PageData}; + +/// The RPC response carries only a sample; the full set goes to the stream. +const SAMPLE_MAX: usize = 10; +/// Ceiling on the per-page politeness delay (see the note where it is read). +const MAX_DOWNLOAD_DELAY_SECS: f64 = 300.0; + +#[derive(Debug)] +pub struct CrawlOpts { + pub start_urls: Vec<String>, + pub fetcher: String, + pub allowed_domains: Vec<String>, + pub same_domain: bool, + pub max_pages: i64, + pub max_depth: i64, + pub concurrency: usize, + pub download_delay: Duration, + pub stream_name: Value, +} + +impl CrawlOpts { + /// Defaults are crawl.py's, which are hardcoded there rather than + /// configurable: 20 pages, depth 2, same-domain on, no delay. + pub fn from_payload(payload: &Value, max_concurrency: usize) -> Result<Self, String> { + Self::from_payload_for_mode(payload, max_concurrency, SecurityMode::Safe) + } + + pub fn from_payload_for_mode( + payload: &Value, + max_concurrency: usize, + mode: SecurityMode, + ) -> Result<Self, String> { + let mut start_urls = crawl_strings(payload.get("start_urls"), "decode")?; + if start_urls.is_empty() { + if let Some(value) = payload.get("url").filter(|value| json_truthy(value)) { + if let Some(url) = value.as_str() { + start_urls.push(url.to_string()); + } else { + return Err(format!( + "'{}' object has no attribute 'decode'", + python_type(value) + )); + } + } + } + if start_urls.is_empty() { + return Err("provide `start_urls`".to_string()); + } + let fetcher = match payload.get("fetcher") { + None => "http".to_string(), + Some(Value::String(value)) => value.clone(), + Some(value) => { + return Err(format!( + "unknown fetcher: {} (use http|stealthy|dynamic)", + python_repr(value) + )) + } + }; + if !matches!(fetcher.as_str(), "http" | "stealthy" | "dynamic") { + return Err(format!( + "unknown fetcher: {fetcher} (use http|stealthy|dynamic)" + )); + } + let concurrency_ceiling = max_concurrency.max(1).min(i64::MAX as usize) as i64; + Ok(Self { + start_urls, + fetcher, + allowed_domains: crawl_strings(payload.get("allowed_domains"), "lower")? + .into_iter() + // Normalize to the same punycode form host_of/normalize_link + // produce, so an IDN allow-entry matches IDN candidate hosts. + .map(|value| ascii_host(&value)) + .collect(), + same_domain: payload.get("same_domain").is_none_or(json_truthy), + max_pages: python_int(payload, "max_pages", 20)?, + max_depth: python_int(payload, "max_depth", 2)?, + // The caller may ask for less, never for more than the server cap. + concurrency: python_int(payload, "concurrency", concurrency_ceiling)? + .clamp(1, concurrency_ceiling) as usize, + // Clamped for the same reason the fetch timeouts are: + // `from_secs_f64` panics on an unrepresentable value, and a panic + // in a detached handler drops the invocation and hangs the caller. + download_delay: crawl_delay(payload.get("download_delay"), mode)?, + stream_name: payload + .get("stream_name") + .filter(|value| json_truthy(value)) + .cloned() + .unwrap_or_else(|| json!("browser::crawl")), + }) + } +} + +fn python_type(value: &Value) -> &'static str { + match value { + Value::Null => "NoneType", + Value::Bool(_) => "bool", + Value::Number(value) if value.is_f64() => "float", + Value::Number(_) => "int", + Value::String(_) => "str", + Value::Array(_) => "list", + Value::Object(_) => "dict", + } +} + +pub(crate) fn python_repr(value: &Value) -> String { + match value { + Value::Null => "None".into(), + Value::Bool(true) => "True".into(), + Value::Bool(false) => "False".into(), + Value::String(value) => format!("'{value}'"), + Value::Number(value) => value.to_string(), + Value::Array(values) => format!( + "[{}]", + values + .iter() + .map(python_repr) + .collect::<Vec<_>>() + .join(", ") + ), + Value::Object(values) => format!( + "{{{}}}", + values + .iter() + .map(|(key, value)| format!("'{key}': {}", python_repr(value))) + .collect::<Vec<_>>() + .join(", ") + ), + } +} + +fn crawl_strings(value: Option<&Value>, method: &str) -> Result<Vec<String>, String> { + let Some(value) = value.filter(|value| json_truthy(value)) else { + return Ok(Vec::new()); + }; + let values = match value { + Value::String(value) => return Ok(value.chars().map(String::from).collect()), + Value::Array(values) => values + .iter() + .map(|value| { + value.as_str().map(str::to_string).ok_or_else(|| { + format!( + "'{}' object has no attribute '{method}'", + python_type(value) + ) + }) + }) + .collect(), + Value::Object(values) => Ok(values.keys().cloned().collect()), + _ => Err(format!("'{}' object is not iterable", python_type(value))), + }?; + Ok(values) +} + +fn python_int(payload: &Value, key: &str, default: i64) -> Result<i64, String> { + match payload.get(key) { + None | Some(Value::Null) => Ok(default), + Some(Value::Bool(value)) => Ok(i64::from(*value)), + Some(Value::Number(value)) => Ok(value + .as_i64() + .or_else(|| { + value + .as_u64() + .map(|value| value.min(i64::MAX as u64) as i64) + }) + .or_else(|| value.as_f64().map(|value| value as i64)) + .unwrap_or(default)), + Some(Value::String(value)) => value + .trim() + .parse() + .map_err(|_| format!("invalid literal for int() with base 10: '{}'", value)), + Some(value) => Err(format!( + "int() argument must be a string, a bytes-like object or a real number, not '{}'", + python_type(value) + )), + } +} + +fn crawl_delay(value: Option<&Value>, mode: SecurityMode) -> Result<Duration, String> { + let value = match value { + None | Some(Value::Null | Value::Bool(false)) => 0.0, + Some(Value::Bool(true)) => 1.0, + Some(Value::Number(value)) => value.as_f64().unwrap_or_default(), + Some(Value::String(value)) if value.is_empty() => 0.0, + Some(Value::String(value)) => value + .trim() + .parse::<f64>() + .map_err(|_| format!("could not convert string to float: '{value}'"))?, + Some(Value::Array(value)) if value.is_empty() => 0.0, + Some(Value::Object(value)) if value.is_empty() => 0.0, + Some(Value::Array(_)) => { + return Err("float() argument must be a string or a real number, not 'list'".into()) + } + Some(Value::Object(_)) => { + return Err("float() argument must be a string or a real number, not 'dict'".into()) + } + }; + let value = if value <= 0.0 { + 0.0 + } else if mode == SecurityMode::Safe { + value.min(MAX_DOWNLOAD_DELAY_SECS) + } else { + value + }; + Duration::try_from_secs_f64(value).map_err(|_| "timestamp too large to convert".to_string()) +} + +/// Host with a leading `www.` folded away, so `example.com` and +/// `www.example.com` count as one site (crawl.py `_same_site`). +fn fold_www(host: &str) -> &str { + host.strip_prefix("www.").unwrap_or(host) +} + +/// Same site if either host is the other, or a subdomain of it, after folding +/// `www.` — the relationship holds in both directions, as in the reference. +pub fn same_site(a: &str, b: &str) -> bool { + let (a, b) = (fold_www(a), fold_www(b)); + a == b || a.ends_with(&format!(".{b}")) || b.ends_with(&format!(".{a}")) +} + +/// Normalize a bare domain to its lowercase ASCII/punycode form, matching +/// what `host_of` yields for a full URL. Falls back to the lowercased input +/// for anything url can't parse as a host. +fn ascii_host(domain: &str) -> String { + url::Url::parse(&format!("http://{domain}")) + .ok() + .and_then(|url| url.host_str().map(str::to_lowercase)) + .unwrap_or_else(|| domain.to_lowercase()) +} + +pub fn host_of(raw: &str) -> Option<String> { + // Use url::Url's host, not a raw netloc scan: extracted links are + // re-serialized through url::Url (punycode), so an IDN seed scanned raw + // (`münchen.example`) would never match a candidate (`xn--mnchen-3ya…`) + // and the crawl would follow zero links. Normalizing both sides the same + // way keeps IDN crawls working. The non-default port is kept, matching + // urllib's netloc (so `e.com:8443` and `e.com:9443` stay distinct sites). + let url = url::Url::parse(raw).ok()?; + let host = url.host_str()?.to_lowercase(); + Some(match url.port() { + Some(port) => format!("{host}:{port}"), + None => host, + }) +} + +/// Should we follow this link? (crawl.py `_domain_ok`) +pub fn domain_ok(candidate: &str, opts: &CrawlOpts, seed_hosts: &[String]) -> bool { + let Some(host) = host_of(candidate) else { + return false; + }; + if !opts.allowed_domains.is_empty() { + return opts + .allowed_domains + .iter() + .any(|d| host == *d || host.ends_with(&format!(".{d}"))); + } + if opts.same_domain { + return seed_hosts.iter().any(|s| same_site(&host, s)); + } + true +} + +/// Absolutise against the page URL and drop the `#fragment`, so `p#a` and +/// `p#b` collapse onto one already-seen URL (crawl.py uses `urldefrag`). +pub fn normalize_link(base: &str, href: &str) -> Option<String> { + let base = url::Url::parse(base).ok()?; + let mut joined = base.join(href).ok()?; + joined.set_fragment(None); + if !matches!(joined.scheme(), "http" | "https") { + return None; + } + Some(joined.to_string()) +} + +/// Every `<a href>` on the page, absolutised and fragment-stripped. +pub fn extract_links(html: &str, base: &str) -> Vec<String> { + let doc = dom::parse(html); + dom::descendant_elements(doc.root()) + .into_iter() + .filter(|element| element.name() == "a") + .filter_map(|element| element.attr("href")) + .filter_map(|href| normalize_link(base, href)) + .collect() +} + +pub fn fetch_payload(payload: &Value, url: &str) -> Value { + const FETCH_KEYS: &[&str] = &[ + "impersonate", + "proxy", + "headless", + "network_idle", + "solve_cloudflare", + "real_chrome", + "wait_selector", + "timeout", + "useragent", + ]; + let mut request = serde_json::Map::new(); + for key in FETCH_KEYS { + if let Some(value) = payload.get(*key) { + request.insert((*key).into(), value.clone()); + } + } + request.insert("url".into(), json!(url)); + if let Some(value) = payload.get("selectors").filter(|value| json_truthy(value)) { + request.insert("selectors".into(), value.clone()); + } + if let Some(value) = payload.get("format").filter(|value| json_truthy(value)) { + request.insert("format".into(), value.clone()); + for key in ["main_content_only", "css_selector"] { + if let Some(value) = payload.get(key).filter(|value| !value.is_null()) { + request.insert(key.into(), value.clone()); + } + } + } + Value::Object(request) +} + +pub struct CrawlOutcome { + /// A bounded SAMPLE for the RPC response — never the full set. Use + /// `item_count` for the real total; `items.len()` caps at `SAMPLE_MAX`. + pub items: Vec<Value>, + /// Pages that produced a result, matching crawl.py's `stats["items"]`. + pub item_count: usize, + pub crawled: usize, + pub errors: usize, + pub stopped: &'static str, +} + +/// Walk the frontier. `fetch` does the I/O; `emit` receives every item (in +/// completion order) for streaming. Neither is allowed to abort the crawl: +/// a failing page becomes an `{url, error}` item, exactly as in the reference +/// where `visit()` never raises. +pub async fn run<F, Fut, E>( + opts: &CrawlOpts, + payload: &Value, + fetch: F, + mut emit: E, +) -> CrawlOutcome +where + F: Fn(String) -> Fut, + Fut: std::future::Future<Output = Result<PageData, String>>, + E: for<'a> FnMut( + &'a Value, + ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>>, +{ + let seed_hosts: Vec<String> = opts.start_urls.iter().filter_map(|u| host_of(u)).collect(); + let include_html = crate::scrapling::page::include_html(payload); + + let mut frontier: VecDeque<(String, i64)> = + opts.start_urls.iter().map(|u| (u.clone(), 0)).collect(); + let mut seen: HashSet<String> = opts.start_urls.iter().cloned().collect(); + let mut items = Vec::new(); + let (mut crawled, mut errors, mut started, mut item_count) = (0usize, 0usize, 0usize, 0usize); + + let mut pending = FuturesUnordered::new(); + loop { + while pending.len() < opts.concurrency && (started as i64) < opts.max_pages { + let Some((url, depth)) = frontier.pop_front() else { + break; + }; + started += 1; + let fut = fetch(url.clone()); + pending.push(async move { (url, depth, fut.await) }); + } + let Some(first) = pending.next().await else { + break; + }; + + // `asyncio.wait(..., FIRST_COMPLETED)` returns every task that is + // already done, not just the one that woke the scheduler. Process that + // whole batch before refilling the pool or applying the delay. + let mut done = vec![first]; + while let Some(Some(completed)) = pending.next().now_or_never() { + done.push(completed); + } + for (url, depth, result) in done { + let item = match result { + Ok(page) => match serialize_page(&page, payload, include_html) { + Ok(serialized) => { + if depth < opts.max_depth { + for link in extract_links(&page.html, &page.url) { + if seen.contains(&link) || !domain_ok(&link, opts, &seed_hosts) { + continue; + } + seen.insert(link.clone()); + frontier.push_back((link, depth + 1)); + } + } + reduce_page(&url, serialized, include_html) + } + Err(e) => { + errors += 1; + json!({"url": url, "error": e}) + } + }, + Err(e) => { + errors += 1; + json!({"url": url, "error": e}) + } + }; + crawled += 1; + if item.get("error").is_none() { + item_count += 1; + } + emit(&item).await; + if items.len() < SAMPLE_MAX { + items.push(item); + } + } + if !opts.download_delay.is_zero() { + tokio::time::sleep(opts.download_delay).await; + } + } + + CrawlOutcome { + items, + item_count, + crawled, + errors, + // Anything left in the frontier means the page cap, not exhaustion, + // ended the crawl. + stopped: if frontier.is_empty() { + "done" + } else { + "max_pages" + }, + } +} + +fn reduce_page(url: &str, page: Value, include_html: bool) -> Value { + let mut item = serde_json::Map::new(); + item.insert("url".into(), json!(url)); + item.insert( + "status".into(), + page.get("status").cloned().unwrap_or(Value::Null), + ); + if page.get("extracted").is_some_and(json_truthy) { + item.insert("extracted".into(), page["extracted"].clone()); + } + if page.get("content").is_some_and(|value| !value.is_null()) { + item.insert("content".into(), page["content"].clone()); + } + if include_html && page.get("html").is_some_and(|value| !value.is_null()) { + item.insert("html".into(), page["html"].clone()); + } + Value::Object(item) +} + +pub(crate) fn json_truthy(value: &Value) -> bool { + match value { + Value::Null => false, + Value::Bool(value) => *value, + Value::Number(value) => value.as_f64() != Some(0.0), + Value::String(value) => !value.is_empty(), + Value::Array(value) => !value.is_empty(), + Value::Object(value) => !value.is_empty(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + fn opts(payload: Value) -> CrawlOpts { + CrawlOpts::from_payload(&payload, 5).unwrap() + } + + fn page(url: &str, html: &str) -> PageData { + PageData { + status: Some(200), + url: url.to_string(), + html: html.to_string(), + ..Default::default() + } + } + + /// A canned site: url -> html. Anything not in the map is a fetch error. + async fn run_on(o: &CrawlOpts, site: &[(&str, &str)], payload: &Value) -> CrawlOutcome { + let order = RefCell::new(Vec::new()); + let out = run( + o, + payload, + |url| { + order.borrow_mut().push(url.clone()); + let found = site + .iter() + .find(|(u, _)| *u == url) + .map(|(u, h)| page(u, h)); + async move { found.ok_or_else(|| "404 not found".to_string()) } + }, + |_| Box::pin(async {}), + ) + .await; + out + } + + #[test] + fn same_site_folds_www_both_directions() { + assert!(same_site("example.com", "www.example.com")); + assert!(same_site("blog.example.com", "example.com")); + assert!(same_site("example.com", "blog.example.com")); + assert!(!same_site("example.com", "example.org")); + assert!(!same_site("notexample.com", "example.com")); + } + + #[test] + fn links_are_absolutised_and_fragment_stripped() { + let links = extract_links( + r#"<a href="/a">1</a><a href="/a#x">2</a><a href="https://o.com/b">3</a> + <a href="mailto:x@y.z">4</a>"#, + "https://e.com/start", + ); + assert_eq!( + links, + vec![ + "https://e.com/a", + "https://e.com/a", // #x stripped -> same url, deduped by `seen` + "https://o.com/b", + ] + ); + } + + #[test] + fn allowed_domains_overrides_same_domain() { + let o = opts(json!({"url": "https://e.com/", "allowed_domains": ["o.com"]})); + let seeds = vec!["e.com".to_string()]; + assert!(domain_ok("https://o.com/x", &o, &seeds)); + assert!(domain_ok("https://sub.o.com/x", &o, &seeds)); + assert!(!domain_ok("https://e.com/x", &o, &seeds)); + } + + #[tokio::test] + async fn breadth_first_order_and_fragment_dedup() { + let o = opts(json!({"url": "https://e.com/", "max_depth": 2, "concurrency": 1})); + let site = [ + ( + "https://e.com/", + r#"<a href="/a">a</a><a href="/b">b</a><a href="/a#dup">a</a>"#, + ), + ("https://e.com/a", r#"<a href="/c">c</a>"#), + ("https://e.com/b", ""), + ("https://e.com/c", ""), + ]; + let out = run_on(&o, &site, &json!({})).await; + // seed, then depth 1 (a, b), then depth 2 (c) — and /a only once + assert_eq!(out.crawled, 4); + assert_eq!(out.errors, 0); + assert_eq!(out.stopped, "done"); + } + + #[tokio::test] + async fn max_depth_zero_visits_only_the_seeds() { + let o = opts(json!({"url": "https://e.com/", "max_depth": 0})); + let site = [ + ("https://e.com/", r#"<a href="/a">a</a>"#), + ("https://e.com/a", ""), + ]; + let out = run_on(&o, &site, &json!({})).await; + assert_eq!(out.crawled, 1); + assert_eq!(out.stopped, "done"); + } + + #[tokio::test] + async fn page_cap_stops_and_reports_max_pages() { + let o = opts(json!({"url": "https://e.com/", "max_pages": 2, "max_depth": 3})); + let site = [ + ("https://e.com/", r#"<a href="/a">a</a><a href="/b">b</a>"#), + ("https://e.com/a", ""), + ("https://e.com/b", ""), + ]; + let out = run_on(&o, &site, &json!({})).await; + assert_eq!(out.crawled, 2); + assert_eq!(out.stopped, "max_pages"); + } + + #[tokio::test] + async fn a_failing_page_never_sinks_the_crawl() { + let o = opts(json!({"url": "https://e.com/", "max_depth": 1})); + // /missing is not in the canned site -> fetch error + let site = [ + ( + "https://e.com/", + r#"<a href="/missing">x</a><a href="/ok">y</a>"#, + ), + ("https://e.com/ok", ""), + ]; + let out = run_on(&o, &site, &json!({})).await; + assert_eq!(out.crawled, 3); + assert_eq!(out.errors, 1); + let err_item = out + .items + .iter() + .find(|i| i.get("error").is_some()) + .expect("the failed page is still reported"); + assert_eq!(err_item["url"], json!("https://e.com/missing")); + assert_eq!(err_item["error"], json!("404 not found")); + } + + #[tokio::test] + async fn off_site_links_are_not_followed_by_default() { + let o = opts(json!({"url": "https://e.com/", "max_depth": 2})); + let site = [ + ("https://e.com/", r#"<a href="https://other.com/x">x</a>"#), + ("https://other.com/x", ""), + ]; + let out = run_on(&o, &site, &json!({})).await; + assert_eq!(out.crawled, 1, "off-site link must not be crawled"); + } + + #[tokio::test] + async fn response_items_are_sampled_but_every_item_is_emitted() { + let o = opts(json!({"url": "https://e.com/p0", "max_pages": 30, "max_depth": 1})); + // one seed linking to 20 pages + let links: String = (1..=20) + .map(|i| format!(r#"<a href="/p{i}">p</a>"#)) + .collect(); + let mut site: Vec<(String, String)> = vec![("https://e.com/p0".into(), links)]; + for i in 1..=20 { + site.push((format!("https://e.com/p{i}"), String::new())); + } + let refs: Vec<(&str, &str)> = site.iter().map(|(u, h)| (u.as_str(), h.as_str())).collect(); + + let emitted = RefCell::new(0usize); + let out = run( + &o, + &json!({}), + |url| { + let found = refs + .iter() + .find(|(u, _)| *u == url) + .map(|(u, h)| page(u, h)); + async move { found.ok_or_else(|| "missing".to_string()) } + }, + |_| { + *emitted.borrow_mut() += 1; + Box::pin(async {}) + }, + ) + .await; + + assert_eq!(out.crawled, 21); + assert_eq!(*emitted.borrow(), 21, "every page is streamed"); + assert_eq!(out.items.len(), SAMPLE_MAX, "the response only samples"); + // The reported count is the real one, not the sample size — reporting + // items=10 for a 21-page crawl would silently cap forever. + assert_eq!(out.item_count, 21); + } + + #[tokio::test] + async fn item_count_counts_successes_only_and_errors_count_separately() { + let o = opts(json!({"url": "https://e.com/", "max_depth": 1})); + let site = [ + ( + "https://e.com/", + r#"<a href="/gone">x</a><a href="/ok">y</a>"#, + ), + ("https://e.com/ok", ""), + ]; + let out = run_on(&o, &site, &json!({})).await; + assert_eq!(out.crawled, 3, "every visit counts as crawled"); + assert_eq!(out.errors, 1); + assert_eq!(out.item_count, 2, "the failed page is not an item"); + } + + #[test] + fn concurrency_is_clamped_to_the_server_cap() { + let o = CrawlOpts::from_payload(&json!({"url": "https://e.com/"}), 5).unwrap(); + assert_eq!( + o.concurrency, 5, + "the oracle defaults to the configured ceiling" + ); + let o = CrawlOpts::from_payload(&json!({"url": "https://e.com/", "concurrency": 99}), 5) + .unwrap(); + assert_eq!(o.concurrency, 5); + let o = CrawlOpts::from_payload(&json!({"url": "https://e.com/", "concurrency": 0}), 5) + .unwrap(); + assert_eq!(o.concurrency, 1); + } + + #[test] + fn domain_keys_include_the_port_like_urllib_netloc() { + assert_eq!( + host_of("https://e.com:8443/a").as_deref(), + Some("e.com:8443") + ); + assert!(!same_site("e.com:8443", "e.com:9443")); + } + + #[tokio::test] + async fn negative_page_cap_starts_nothing() { + let o = opts(json!({"url": "https://e.com/", "max_pages": -1})); + let out = run_on(&o, &[("https://e.com/", "")], &json!({})).await; + assert_eq!(out.crawled, 0); + assert_eq!(out.stopped, "max_pages"); + } + + #[tokio::test] + async fn crawl_sample_has_the_reduced_wrapper_shape() { + let o = opts(json!({"url": "https://e.com/"})); + let out = run_on( + &o, + &[("https://e.com/", "<h1>Hi</h1>")], + &json!({"selectors": [{"name": "h", "css": "h1"}]}), + ) + .await; + assert_eq!( + out.items, + vec![json!({ + "url": "https://e.com/", + "status": 200, + "extracted": {"h": "Hi"}, + })] + ); + } + + #[tokio::test] + async fn a_completion_batch_is_recorded_before_refilling_the_pool() { + let o = opts(json!({ + "start_urls": ["https://e.com/1", "https://e.com/2", "https://e.com/3"], + "concurrency": 2, + "max_depth": 0, + })); + let events = RefCell::new(Vec::new()); + let out = run( + &o, + &json!({}), + |url| { + events.borrow_mut().push(format!("start:{url}")); + async move { Ok(page(&url, "")) } + }, + |item| { + events + .borrow_mut() + .push(format!("emit:{}", item["url"].as_str().unwrap())); + Box::pin(async {}) + }, + ) + .await; + assert_eq!(out.crawled, 3); + + let events = events.into_inner(); + let third_start = events + .iter() + .position(|event| event == "start:https://e.com/3") + .unwrap(); + assert_eq!( + events[..third_start] + .iter() + .filter(|event| event.starts_with("emit:")) + .count(), + 2, + "the oracle processes every task returned by FIRST_COMPLETED before refilling: {events:?}" + ); + } + + #[test] + fn missing_start_urls_and_bad_fetcher_are_rejected() { + assert_eq!( + CrawlOpts::from_payload(&json!({}), 5).unwrap_err(), + "provide `start_urls`" + ); + assert!( + CrawlOpts::from_payload(&json!({"url": "u", "fetcher": "carrier"}), 5) + .unwrap_err() + .contains("unknown fetcher") + ); + } + + #[test] + fn wrapper_coercions_and_errors_match_python() { + assert_eq!( + CrawlOpts::from_payload(&json!({"url": "https://e.com/", "max_pages": [1]}), 5) + .unwrap_err(), + "int() argument must be a string, a bytes-like object or a real number, not 'list'" + ); + assert_eq!( + CrawlOpts::from_payload(&json!({"url": "https://e.com/", "max_depth": "x"}), 5) + .unwrap_err(), + "invalid literal for int() with base 10: 'x'" + ); + assert_eq!( + CrawlOpts::from_payload(&json!({"url": "https://e.com/", "fetcher": null}), 5) + .unwrap_err(), + "unknown fetcher: None (use http|stealthy|dynamic)" + ); + assert_eq!( + CrawlOpts::from_payload(&json!({"start_urls": [1]}), 5).unwrap_err(), + "'int' object has no attribute 'decode'" + ); + + let options = opts(json!({ + "start_urls": "ab", + "allowed_domains": {"EXAMPLE.COM": true}, + "same_domain": 0, + "max_pages": " 2 " + })); + assert_eq!(options.start_urls, ["a", "b"]); + assert_eq!(options.allowed_domains, ["example.com"]); + assert!(!options.same_domain); + assert_eq!(options.max_pages, 2); + } + + #[test] + fn per_page_payload_only_forwards_the_wrapper_allowlist() { + let request = fetch_payload( + &json!({ + "url": "https://old.invalid", + "headers": {"x": "silently excluded by the oracle crawl wrapper"}, + "impersonate": "chrome", + "timeout": 9, + "selectors": [{"name": "h", "css": "h1"}], + "format": "text", + "main_content_only": false, + "css_selector": "main", + "include_html": true, + }), + "https://e.com/page", + ); + assert_eq!( + request, + json!({ + "impersonate": "chrome", + "timeout": 9, + "url": "https://e.com/page", + "selectors": [{"name": "h", "css": "h1"}], + "format": "text", + "main_content_only": false, + "css_selector": "main", + }) + ); + } + + #[test] + fn an_absurd_download_delay_is_clamped_not_panicked_on() { + for v in [1e20, f64::MAX] { + let o = opts(json!({"url": "https://e.com/", "download_delay": v})); + assert!(o.download_delay <= Duration::from_secs_f64(MAX_DOWNLOAD_DELAY_SECS)); + } + assert_eq!( + opts(json!({"url": "https://e.com/", "download_delay": f64::NAN})).download_delay, + Duration::ZERO + ); + assert_eq!( + CrawlOpts::from_payload_for_mode( + &json!({"url": "https://e.com/", "download_delay": 301}), + 5, + SecurityMode::Compat, + ) + .unwrap() + .download_delay, + Duration::from_secs(301) + ); + assert_eq!( + CrawlOpts::from_payload_for_mode( + &json!({"url": "https://e.com/", "download_delay": -1}), + 5, + SecurityMode::Compat, + ) + .unwrap() + .download_delay, + Duration::ZERO + ); + assert_eq!( + CrawlOpts::from_payload(&json!({"url": "https://e.com/", "download_delay": [1]}), 5,) + .unwrap_err(), + "float() argument must be a string or a real number, not 'list'" + ); + } + + #[test] + fn stream_name_defaults_to_our_namespace() { + assert_eq!(opts(json!({"url": "u"})).stream_name, "browser::crawl"); + assert_eq!( + opts(json!({"url": "u", "stream_name": 3})).stream_name, + json!(3) + ); + } +} diff --git a/browser/src/scrapling/dom.rs b/browser/src/scrapling/dom.rs new file mode 100644 index 000000000..c25cfb3cc --- /dev/null +++ b/browser/src/scrapling/dom.rs @@ -0,0 +1,521 @@ +//! Libxml-compatible HTML tree used by every Scrapling parser operation. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; + +use serde_json::Value; +use xmloxide::html::{parse_html_with_options, HtmlParseOptions}; +use xmloxide::serial::html::serialize_html_subtree; +use xmloxide::tree::NodeKind; +use xmloxide::{Document, NodeId}; + +const LXML_MIXED_CONTENT_TAGS: &[&str] = &[ + "body", + "div", + "p", + "span", + "a", + "b", + "i", + "u", + "s", + "strike", + "tt", + "big", + "small", + "cite", + "q", + "kbd", + "ins", + "del", + "em", + "strong", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "li", + "dt", + "dd", + "td", + "th", + "caption", + "pre", + "code", + "label", + "button", + "legend", + "address", + "blockquote", + "form", +]; +const LXML_RAW_TEXT_TAGS: &[&str] = &["script", "style", "title", "textarea"]; + +#[derive(Debug, Clone)] +pub struct Doc { + pub tree: Document, +} + +#[derive(Debug, Clone, Copy)] +pub struct ElementRef<'a> { + doc: &'a Doc, + id: NodeId, +} + +impl PartialEq for ElementRef<'_> { + fn eq(&self, other: &Self) -> bool { + std::ptr::eq(self.doc, other.doc) && self.id == other.id + } +} + +impl Eq for ElementRef<'_> {} + +impl Hash for ElementRef<'_> { + fn hash<H: Hasher>(&self, state: &mut H) { + std::ptr::from_ref(self.doc).hash(state); + self.id.hash(state); + } +} + +impl<'a> ElementRef<'a> { + pub fn id(self) -> NodeId { + self.id + } + + pub fn doc(self) -> &'a Doc { + self.doc + } + + pub fn name(self) -> &'a str { + self.doc.tree.node_name(self.id).unwrap_or("") + } + + pub fn attr(self, name: &str) -> Option<&'a str> { + self.doc.tree.attribute(self.id, name) + } + + pub fn attrs(self) -> impl Iterator<Item = (&'a str, &'a str)> + 'a { + self.doc + .tree + .attributes(self.id) + .iter() + .map(|attr| (attr.name.as_str(), attr.value.as_str())) + } +} + +pub fn parse(input: &str) -> Doc { + let cleaned = input.trim().replace('\0', ""); + let trim_implicit_leading = (cleaned.starts_with("<!--") || cleaned.starts_with("<![CDATA[")) + && !cleaned.to_ascii_lowercase().contains("<body"); + let body = if cleaned.is_empty() { + "<html></html>" + } else { + &cleaned + }; + let options = HtmlParseOptions::default().recover(true).no_blanks(false); + let mut tree = parse_html_with_options(body, &options).unwrap_or_else(|_| { + parse_html_with_options("<html></html>", &HtmlParseOptions::default()).unwrap() + }); + if tree.root_element().is_none() { + tree = parse_html_with_options(&format!("<html><body>{body}</body></html>"), &options) + .unwrap_or_else(|_| parse_html_with_options("<html></html>", &options).unwrap()); + } + + let removed: Vec<_> = tree + .descendants(tree.root()) + .filter(|id| { + matches!( + tree.node(*id).kind, + // lxml's cleaner drops PIs too; keeping them made ::text / + // find-by-text / describe / get_all_text diverge. + NodeKind::Comment { .. } + | NodeKind::CData { .. } + | NodeKind::ProcessingInstruction { .. } + ) + }) + .collect(); + for id in removed { + tree.remove_node(id); + } + if trim_implicit_leading { + trim_body_leading_whitespace(&mut tree); + } + merge_adjacent_text(&mut tree); + detach_blank_text(&mut tree); + Doc { tree } +} + +fn trim_body_leading_whitespace(tree: &mut Document) { + let body = tree + .descendants(tree.root()) + .find(|id| tree.node_name(*id) == Some("body")); + let first = body.and_then(|id| tree.first_child(id)); + if let Some(id) = first.filter(|id| is_text(tree, *id)) { + let trimmed = tree.node_text(id).unwrap_or("").trim_start().to_string(); + tree.set_text_content(id, &trimmed); + } +} + +fn is_text(tree: &Document, id: NodeId) -> bool { + matches!( + tree.node(id).kind, + NodeKind::Text { .. } | NodeKind::CData { .. } + ) +} + +fn merge_adjacent_text(tree: &mut Document) { + let parents: Vec<_> = std::iter::once(tree.root()) + .chain(tree.descendants(tree.root())) + .collect(); + for parent in parents { + let children: Vec<_> = tree.children(parent).collect(); + let mut previous = None; + for child in children { + if !is_text(tree, child) { + previous = None; + continue; + } + if let Some(first) = previous { + let merged = format!( + "{}{}", + tree.node_text(first).unwrap_or(""), + tree.node_text(child).unwrap_or("") + ); + tree.set_text_content(first, &merged); + tree.remove_node(child); + } else { + previous = Some(child); + } + } + } +} + +fn detach_blank_text(tree: &mut Document) { + let ids: Vec<_> = tree.descendants(tree.root()).collect(); + let mut drop_ids = Vec::new(); + for id in ids { + if !is_text(tree, id) + || tree + .prev_sibling(id) + .is_some_and(|prev| is_text(tree, prev)) + { + continue; + } + let mut run = vec![id]; + let mut all_blank = tree + .node_text(id) + .is_some_and(|text| text.chars().all(char::is_whitespace)); + let mut next = tree.next_sibling(id); + while let Some(node) = next { + if !is_text(tree, node) { + break; + } + all_blank &= tree + .node_text(node) + .is_some_and(|text| text.chars().all(char::is_whitespace)); + run.push(node); + next = tree.next_sibling(node); + } + if !all_blank { + continue; + } + let previous_tag = tree.prev_sibling(id).and_then(|node| tree.node_name(node)); + let keep = previous_tag.map_or_else( + || { + tree.parent(id) + .and_then(|node| tree.node_name(node)) + .is_some_and(|name| { + LXML_MIXED_CONTENT_TAGS.contains(&name) + || LXML_RAW_TEXT_TAGS.contains(&name) + }) + }, + |name| LXML_MIXED_CONTENT_TAGS.contains(&name), + ); + if !keep { + drop_ids.extend(run); + } + } + for id in drop_ids { + tree.remove_node(id); + } +} + +impl Doc { + pub fn root(&self) -> ElementRef<'_> { + let id = self + .tree + .root_element() + .expect("HTML parser always emits a root"); + ElementRef { doc: self, id } + } + + pub fn element(&self, id: NodeId) -> Option<ElementRef<'_>> { + self.tree + .is_element(id) + .then_some(ElementRef { doc: self, id }) + } + + pub fn first_by_tag(&self, tag: &str) -> Option<ElementRef<'_>> { + std::iter::once(self.root()) + .chain(descendant_elements(self.root())) + .find(|element| element.name() == tag) + } +} + +pub fn merged_text_children(el: ElementRef<'_>) -> Vec<(bool, String)> { + let tree = &el.doc.tree; + let mut runs = Vec::new(); + let mut seen_element = false; + let mut current: Option<String> = None; + for child in tree.children(el.id) { + if is_text(tree, child) { + current + .get_or_insert_with(String::new) + .push_str(tree.node_text(child).unwrap_or("")); + } else { + if let Some(run) = current.take() { + runs.push((!seen_element, run)); + } + seen_element |= tree.is_element(child); + } + } + if let Some(run) = current { + runs.push((!seen_element, run)); + } + runs +} + +pub fn descendant_text_runs(el: ElementRef<'_>) -> Vec<(ElementRef<'_>, usize, String)> { + let tree = &el.doc.tree; + let mut out = Vec::new(); + let mut indexes: HashMap<NodeId, usize> = HashMap::new(); + let mut owner = None; + let mut current = String::new(); + for node in tree.descendants(el.id) { + if !is_text(tree, node) { + flush_run(&mut owner, &mut current, &mut out, &mut indexes); + continue; + } + let parent = tree.parent(node).and_then(|id| el.doc.element(id)); + if parent.map(ElementRef::id) != owner.map(ElementRef::id) { + flush_run(&mut owner, &mut current, &mut out, &mut indexes); + owner = parent; + } + current.push_str(tree.node_text(node).unwrap_or("")); + } + flush_run(&mut owner, &mut current, &mut out, &mut indexes); + out +} + +fn flush_run<'a>( + owner: &mut Option<ElementRef<'a>>, + text: &mut String, + out: &mut Vec<(ElementRef<'a>, usize, String)>, + indexes: &mut HashMap<NodeId, usize>, +) { + if let Some(element) = owner.take() { + let index = indexes.entry(element.id).or_default(); + out.push((element, *index, std::mem::take(text))); + *index += 1; + } +} + +pub fn leading_text(el: ElementRef<'_>) -> String { + merged_text_children(el) + .first() + .filter(|(leading, _)| *leading) + .map(|(_, text)| text.clone()) + .unwrap_or_default() +} + +pub fn first_text_run_nonblank(el: ElementRef<'_>) -> bool { + merged_text_children(el) + .first() + .is_some_and(|(_, text)| !crate::scrapling::text::normalize_space(text).is_empty()) +} + +pub fn get_all_text( + el: ElementRef<'_>, + separator: &str, + strip: bool, + ignore: &[&str], + valid_values: bool, +) -> String { + let tree = &el.doc.tree; + let mut fragments = Vec::new(); + let mut owner = None; + let mut current = String::new(); + + let flush = |current: &mut String, fragments: &mut Vec<String>| { + if current.is_empty() { + return; + } + let trimmed = current.trim(); + if !valid_values || !trimmed.is_empty() { + fragments.push(if strip { + trimmed.to_string() + } else { + current.clone() + }); + } + current.clear(); + }; + + for node in tree.descendants(el.id) { + if !is_text(tree, node) { + flush(&mut current, &mut fragments); + owner = None; + continue; + } + let mut cursor = tree.parent(node); + let mut ignored = false; + while let Some(ancestor) = cursor { + if tree + .node_name(ancestor) + .is_some_and(|name| ignore.iter().any(|item| item.eq_ignore_ascii_case(name))) + { + ignored = true; + break; + } + if ancestor == el.id { + break; + } + cursor = tree.parent(ancestor); + } + if ignored { + flush(&mut current, &mut fragments); + owner = None; + continue; + } + let parent = tree.parent(node); + if parent != owner { + flush(&mut current, &mut fragments); + owner = parent; + } + current.push_str(tree.node_text(node).unwrap_or("")); + } + flush(&mut current, &mut fragments); + fragments.join(separator) +} + +pub fn outer_html(el: ElementRef<'_>) -> String { + serialize_html_subtree(&el.doc.tree, el.id) +} + +pub fn attrs_json(el: ElementRef<'_>) -> serde_json::Map<String, Value> { + el.attrs() + .map(|(name, value)| (name.to_string(), Value::String(value.to_string()))) + .collect() +} + +pub fn parent_element(el: ElementRef<'_>) -> Option<ElementRef<'_>> { + el.doc.tree.parent(el.id).and_then(|id| el.doc.element(id)) +} + +pub fn element_children(el: ElementRef<'_>) -> Vec<ElementRef<'_>> { + el.doc + .tree + .children(el.id) + .filter_map(|id| el.doc.element(id)) + .collect() +} + +pub fn descendant_elements(el: ElementRef<'_>) -> Vec<ElementRef<'_>> { + el.doc + .tree + .descendants(el.id) + .filter_map(|id| el.doc.element(id)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn first<'a>(doc: &'a Doc, tag: &str) -> ElementRef<'a> { + doc.first_by_tag(tag).unwrap() + } + + #[test] + fn parse_and_text_match_libxml_cleanup() { + let doc = parse("<span>CONDITION: <!-- hi -->Excellent</span>"); + let span = first(&doc, "span"); + assert_eq!(leading_text(span), "CONDITION: Excellent"); + assert_eq!(outer_html(span), "<span>CONDITION: Excellent</span>"); + + let doc = parse("<p>a\0b</p>"); + assert_eq!(get_all_text(first(&doc, "p"), "\n", false, &[], true), "ab"); + + let doc = parse("<!--c--> b"); + assert_eq!(outer_html(doc.root()), "<html><body>b</body></html>"); + let doc = parse("<div><!--c--> b</div>"); + assert_eq!(outer_html(first(&doc, "div")), "<div> b</div>"); + } + + #[test] + fn empty_input_keeps_the_oracles_explicit_html_root() { + let doc = parse(""); + assert_eq!(doc.root().name(), "html"); + assert_eq!(outer_html(doc.root()), "<html></html>"); + + let doc = parse(">"); + assert_eq!(outer_html(doc.root()), "<html><body>&gt;</body></html>"); + } + + #[test] + fn text_runs_and_ignored_subtrees_match_scrapling() { + let doc = parse("<div>a<script>bad()</script>b<style>.x{}</style>c</div>"); + assert_eq!( + get_all_text(first(&doc, "div"), "\n", false, &["script", "style"], true), + "a\nb\nc" + ); + let doc = parse("<p>lead<b>x</b>tail</p>"); + assert_eq!(leading_text(first(&doc, "p")), "lead"); + } + + #[test] + fn blank_text_uses_libxmls_legacy_content_model() { + let doc = parse("<main> <h1>a</h1> <p>b</p> <h2>c</h2> <table><tr><td>d</td></tr></table> <p>e</p></main>"); + let runs: Vec<_> = merged_text_children(first(&doc, "main")) + .into_iter() + .map(|(_, value)| value) + .collect(); + assert_eq!(runs, [" ", " ", " "]); + assert_eq!( + merged_text_children(first(&parse("<pre> \n </pre>"), "pre")), + [(true, " \n ".to_string())] + ); + } + + #[test] + fn attributes_and_recovery_are_ordered_and_libxml_serialized() { + let doc = parse("<input z=1 disabled a='' z=2 checked=checked>"); + let input = first(&doc, "input"); + assert_eq!( + attrs_json(input).keys().collect::<Vec<_>>(), + ["z", "disabled", "a", "checked"] + ); + assert_eq!(outer_html(input), "<input z=\"1\" disabled a=\"\" checked>"); + + let doc = parse("<table><td>A<td>B<div>C"); + assert_eq!( + outer_html(first(&doc, "table")), + "<table><td>A</td><td>B<div>C</div></td></table>" + ); + } + + #[test] + fn template_contents_are_ordinary_children() { + let doc = parse("<template><p>x</p></template>"); + let template = first(&doc, "template"); + assert_eq!( + element_children(template) + .into_iter() + .map(ElementRef::name) + .collect::<Vec<_>>(), + ["p"] + ); + } +} diff --git a/browser/src/scrapling/egress_gate.rs b/browser/src/scrapling/egress_gate.rs new file mode 100644 index 000000000..89ae33cf4 --- /dev/null +++ b/browser/src/scrapling/egress_gate.rs @@ -0,0 +1,260 @@ +use std::net::SocketAddr; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; + +use crate::ssrf::{check_target, parse_target, SsrfPolicy}; + +const MAX_HEADER_BYTES: usize = 64 * 1024; + +/// Local HTTP/CONNECT proxy used by safe-mode Chromium. Every connection is +/// resolved, checked, and then pinned to the checked address before dialing. +pub struct EgressGate { + address: SocketAddr, + stop: Option<oneshot::Sender<()>>, + task: Option<JoinHandle<()>>, +} + +impl EgressGate { + pub async fn start(policy: SsrfPolicy) -> Result<Self, String> { + let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .map_err(|e| format!("starting browser egress gate: {e}"))?; + let address = listener + .local_addr() + .map_err(|e| format!("reading browser egress gate address: {e}"))?; + let (stop, mut stopped) = oneshot::channel(); + let task = tokio::spawn(async move { + loop { + tokio::select! { + _ = &mut stopped => break, + accepted = listener.accept() => match accepted { + Ok((socket, _)) => { + tokio::spawn(handle(socket, policy)); + } + Err(_) => break, + } + } + } + }); + Ok(Self { + address, + stop: Some(stop), + task: Some(task), + }) + } + + pub fn proxy_url(&self) -> String { + format!("http://{}", self.address) + } + + pub async fn close(mut self) { + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + if let Some(task) = self.task.take() { + let _ = task.await; + } + } +} + +impl Drop for EgressGate { + fn drop(&mut self) { + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + if let Some(task) = self.task.take() { + task.abort(); + } + } +} + +async fn handle(mut client: TcpStream, policy: SsrfPolicy) { + if let Err((status, detail)) = proxy(&mut client, policy).await { + let body = format!("browser egress denied: {detail}\n"); + let _ = client + .write_all( + format!( + "HTTP/1.1 {status}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await; + } +} + +async fn proxy(client: &mut TcpStream, policy: SsrfPolicy) -> Result<(), (&'static str, String)> { + let request = read_head(client).await?; + let split = request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| position + 4) + .ok_or_else(|| ("400 Bad Request", "incomplete request headers".to_string()))?; + let head = std::str::from_utf8(&request[..split]).map_err(|_| { + ( + "400 Bad Request", + "request headers are not UTF-8".to_string(), + ) + })?; + let first_end = head + .find("\r\n") + .ok_or_else(|| ("400 Bad Request", "missing request line".to_string()))?; + let mut request_line = head[..first_end].split_whitespace(); + let method = request_line.next().unwrap_or_default(); + let target = request_line.next().unwrap_or_default(); + let version = request_line.next().unwrap_or_default(); + if request_line.next().is_some() || !version.starts_with("HTTP/") { + return Err(("400 Bad Request", "invalid request line".to_string())); + } + + let (parsed, connect) = if method.eq_ignore_ascii_case("CONNECT") { + (parse_target(&format!("https://{target}/")), true) + } else { + (parse_target(target), false) + }; + let parsed = parsed.map_err(|e| ("400 Bad Request", e))?; + let resolved = check_target(&parsed, &policy) + .await + .map_err(|e| ("403 Forbidden", e.message))?; + let mut upstream = TcpStream::connect(SocketAddr::new(resolved.address, resolved.port)) + .await + .map_err(|e| { + ( + "502 Bad Gateway", + format!("connecting to {}: {e}", parsed.hostname), + ) + })?; + + if connect { + client + .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") + .await + .map_err(|e| ("502 Bad Gateway", e.to_string()))?; + } else { + let path = match parsed.url.query() { + Some(query) => format!("{}?{query}", parsed.url.path()), + None => parsed.url.path().to_string(), + }; + // The upstream socket is pinned to the FIRST validated host; only one + // request may ever travel on it. Strip the client's connection + // management and force `Connection: close` so a keep-alive client + // cannot send a second (differently-addressed) request down this + // pinned socket. + let mut forwarded = format!("{method} {path} {version}\r\n"); + for line in head[first_end + 2..].split("\r\n") { + if line.is_empty() { + continue; + } + let name = line.split(':').next().unwrap_or_default().trim(); + if name.eq_ignore_ascii_case("connection") + || name.eq_ignore_ascii_case("proxy-connection") + || name.eq_ignore_ascii_case("keep-alive") + { + continue; + } + forwarded.push_str(line); + forwarded.push_str("\r\n"); + } + forwarded.push_str("Connection: close\r\n\r\n"); + upstream + .write_all(forwarded.as_bytes()) + .await + .map_err(|e| ("502 Bad Gateway", e.to_string()))?; + } + if request.len() > split { + upstream + .write_all(&request[split..]) + .await + .map_err(|e| ("502 Bad Gateway", e.to_string()))?; + } + tokio::io::copy_bidirectional(client, &mut upstream) + .await + .map_err(|e| ("502 Bad Gateway", e.to_string()))?; + Ok(()) +} + +async fn read_head(client: &mut TcpStream) -> Result<Vec<u8>, (&'static str, String)> { + let mut request = Vec::with_capacity(1024); + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + if request.len() == MAX_HEADER_BYTES { + return Err(( + "431 Request Header Fields Too Large", + "request headers exceed 64 KiB".to_string(), + )); + } + let start = request.len(); + let end = (start + 4096).min(MAX_HEADER_BYTES); + request.resize(end, 0); + let read = client + .read(&mut request[start..end]) + .await + .map_err(|e| ("400 Bad Request", e.to_string()))?; + request.truncate(start + read); + if read == 0 { + return Err(( + "400 Bad Request", + "connection closed before headers".to_string(), + )); + } + } + Ok(request) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn proxies_http_and_blocks_metadata_connects() { + let origin = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .unwrap(); + let origin_address = origin.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = origin.accept().await.unwrap(); + let request = read_head(&mut socket).await.unwrap(); + let request = String::from_utf8(request).unwrap(); + assert!(request.starts_with("GET /path?q=1 HTTP/1.1\r\n")); + // Exactly one Connection header, forced to close. + assert_eq!(request.matches("Connection:").count(), 1); + assert!(request.contains("\r\nConnection: close\r\n")); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + .await + .unwrap(); + }); + let gate = EgressGate::start(SsrfPolicy { + allow_loopback: true, + }) + .await + .unwrap(); + let gate_address = gate.address; + let mut client = TcpStream::connect(gate_address).await.unwrap(); + client + .write_all( + format!( + "GET http://{origin_address}/path?q=1 HTTP/1.1\r\nHost: {origin_address}\r\nConnection: close\r\n\r\n" + ) + .as_bytes(), + ) + .await + .unwrap(); + let mut response = String::new(); + client.read_to_string(&mut response).await.unwrap(); + assert!(response.ends_with("\r\n\r\nok")); + server.await.unwrap(); + + let mut client = TcpStream::connect(gate_address).await.unwrap(); + client + .write_all(b"CONNECT 169.254.169.254:80 HTTP/1.1\r\nHost: 169.254.169.254\r\n\r\n") + .await + .unwrap(); + let mut response = String::new(); + client.read_to_string(&mut response).await.unwrap(); + assert!(response.starts_with("HTTP/1.1 403 Forbidden\r\n")); + gate.close().await; + } +} diff --git a/browser/src/scrapling/fetch.rs b/browser/src/scrapling/fetch.rs new file mode 100644 index 000000000..64b79154c --- /dev/null +++ b/browser/src/scrapling/fetch.rs @@ -0,0 +1,1617 @@ +//! `browser::fetch` — the no-browser HTTP tier (core.py's +//! `fetch_raw(tier="http")`). +//! +//! Safe mode uses `reqwest` + rustls and refuses options that would pretend to +//! provide curl-cffi wire parity or bypass its egress/TLS policy. Certified +//! Tier-1 compat builds instead link the frozen curl-impersonate engine behind +//! the `scrapling-compat` feature. +//! +//! In safe mode redirects are followed by hand (`Policy::none`) so every hop +//! goes back through the SSRF check — the initial URL being public says +//! nothing about where hop 3 points. Each hop also pins the socket to the +//! address we validated, which closes the DNS-rebinding window between the +//! check and the connect. + +use std::net::SocketAddr; +use std::time::Duration; + +use serde_json::{json, Map, Value}; + +use crate::scrapling::page::PageData; +use crate::ssrf::{check_target, parse_target, SsrfPolicy}; + +#[cfg(feature = "scrapling-compat")] +mod curl_compat; +#[cfg(feature = "scrapling-compat")] +pub(crate) use curl_compat::CompatSession; + +/// Chrome-ish default headers. The python worker's `stealthy_headers` (on by +/// default) adds a realistic header set plus a Google referer; this is the +/// header-level part of that, which is all we can honestly offer. +const DEFAULT_UA: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) \ + Chrome/131.0.0.0 Safari/537.36"; +const DEFAULT_ACCEPT: &str = + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"; + +/// Ceilings on caller-supplied durations. `Duration::from_secs_f64` panics on +/// unrepresentable input, and an hour is already far past any sane fetch. +const MAX_TIMEOUT_SECS: f64 = 3_600.0; +const MAX_RETRY_DELAY_SECS: f64 = 60.0; +/// Total wall-clock budget for one `fetch` call. Without it, `timeout` is +/// per-request and multiplies out across redirect hops and retries — the +/// reference passes `timeout` to curl's `CURLOPT_TIMEOUT`, which is a TOTAL +/// budget, so a caller asking for 30s must not be able to wait 46 minutes. +const TOTAL_BUDGET_MULTIPLIER: u32 = 3; +/// Upper clamp for `max_redirects` on the safe tier. The schema is a bare +/// integer, so oversized values must clamp rather than panic; anything past +/// this is a redirect loop, not a fetch. +const MAX_REDIRECTS: i64 = 100; +/// Cap on a single response body. Without it one URL can OOM the worker and +/// take every live browser session with it. +const MAX_BODY_BYTES: usize = 32 * 1024 * 1024; + +/// Headers that must not survive a redirect to a different origin. reqwest +/// strips these itself when it follows redirects; we follow them by hand (to +/// re-check each hop against the SSRF policy), so stripping is ours to do. +/// curl has done this since CVE-2018-1000007. +fn is_sensitive_header(name: &str) -> bool { + let n = name.to_ascii_lowercase(); + matches!( + n.as_str(), + "authorization" | "cookie" | "proxy-authorization" | "www-authenticate" + ) +} + +/// Same origin = same scheme, host and effective port. +fn same_origin(a: &url::Url, b: &url::Url) -> bool { + a.scheme() == b.scheme() + && a.host_str() == b.host_str() + && a.port_or_known_default() == b.port_or_known_default() +} + +fn redirected_request(status: u16, method: &str, send_body: bool) -> (String, bool) { + if matches!(status, 301..=303) { + ( + if method == "post" { "get" } else { method }.to_string(), + false, + ) + } else { + (method.to_string(), send_body) + } +} + +#[derive(Clone, Debug)] +pub struct HttpOptions { + pub mode: HttpMode, + pub method: String, + pub timeout: Duration, + /// Preserve curl-cffi's signed millisecond value for compat setopt error + /// parity. Safe mode only consumes the bounded `Duration` above. + pub compat_timeout_ms: i64, + pub follow_redirects: bool, + pub compat_follow_redirects: i64, + pub max_redirects: i64, + pub retries: i64, + pub retry_delay: Duration, + pub retry_delay_negative: bool, + pub proxy: Option<String>, + pub proxies: Vec<(String, String)>, + pub proxy_auth: Option<(String, String)>, + pub auth: Option<(String, String)>, + pub impersonate: Option<String>, + pub http3: bool, + pub verify: bool, + pub headers: Vec<(String, String)>, + pub cookies: Vec<(String, String)>, + pub params: Vec<(String, String)>, + pub body: Option<Body>, + /// Shared cookie jar for `session-fetch`. Each hop still builds its own + /// pinned client, so the jar is what carries cookies across requests (and + /// across redirects) on a persistent HTTP session. + pub jar: Option<std::sync::Arc<reqwest::cookie::Jar>>, + #[cfg(feature = "scrapling-compat")] + pub(crate) compat_session: Option<CompatSession>, +} + +#[derive(Clone, Debug)] +pub enum Body { + Form(Value), + Json(Value), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum HttpMode { + Safe, + Compat, +} + +fn str_pairs(payload: &Value, key: &str) -> Vec<(String, String)> { + payload + .get(key) + .and_then(Value::as_object) + .map(|m| { + m.iter() + .map(|(k, v)| { + let s = match v { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + (k.clone(), s) + }) + .collect::<Vec<_>>() + }) + .unwrap_or_default() +} + +fn python_scalar(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + Value::Bool(value) => if *value { "True" } else { "False" }.to_string(), + Value::Null => "None".to_string(), + Value::Number(value) => value.to_string(), + Value::Array(_) | Value::Object(_) => python_repr(value), + } +} + +fn python_repr(value: &Value) -> String { + match value { + Value::Null => "None".to_string(), + Value::Bool(value) => if *value { "True" } else { "False" }.to_string(), + Value::Number(value) => value.to_string(), + Value::String(value) => { + let quote = if value.contains('\'') && !value.contains('"') { + '"' + } else { + '\'' + }; + let escaped = value + .replace('\\', "\\\\") + .replace(quote, &format!("\\{quote}")); + format!("{quote}{escaped}{quote}") + } + Value::Array(values) => format!( + "[{}]", + values + .iter() + .map(python_repr) + .collect::<Vec<_>>() + .join(", ") + ), + Value::Object(values) => format!( + "{{{}}}", + values + .iter() + .map(|(name, value)| format!( + "{}: {}", + python_repr(&Value::String(name.clone())), + python_repr(value) + )) + .collect::<Vec<_>>() + .join(", ") + ), + } +} + +fn python_json(value: &Value) -> String { + match value { + Value::Array(values) => format!( + "[{}]", + values + .iter() + .map(python_json) + .collect::<Vec<_>>() + .join(", ") + ), + Value::Object(values) => format!( + "{{{}}}", + values + .iter() + .map(|(name, value)| format!( + "{}: {}", + serde_json::to_string(name).expect("JSON string serialization cannot fail"), + python_json(value) + )) + .collect::<Vec<_>>() + .join(", ") + ), + other => other.to_string(), + } +} + +fn params_pairs(payload: &Value) -> Vec<(String, String)> { + let mut pairs = Vec::new(); + if let Some(params) = payload.get("params").and_then(Value::as_object) { + for (name, value) in params { + if let Value::Array(values) = value { + pairs.extend( + values + .iter() + .map(|value| (name.clone(), python_scalar(value))), + ); + } else { + let value = if matches!(value, Value::Bool(_) | Value::Object(_)) { + python_json(value) + } else { + python_scalar(value) + }; + pairs.push((name.clone(), value)); + } + } + } + pairs +} + +fn pair(payload: &Value, key: &str) -> Result<Option<(String, String)>, String> { + let Some(value) = payload.get(key).filter(|value| !value.is_null()) else { + return Ok(None); + }; + let values = value + .as_array() + .ok_or_else(|| format!("{key} must be [username, password]"))?; + if values.len() < 2 { + return Err(format!( + "not enough values to unpack (expected 2, got {})", + values.len() + )); + } + if values.len() > 2 { + return Err("too many values to unpack (expected 2)".to_string()); + } + let string = |index: usize| { + values[index] + .as_str() + .map(str::to_owned) + .ok_or_else(|| format!("{key} must be [username, password]")) + }; + Ok(Some((string(0)?, string(1)?))) +} + +impl HttpOptions { + /// Read the request. Defaults mirror scrapling's own + /// (`engines/static.py`): 30s timeout, 3 attempts, 1s between them, + /// 30 redirect hops. + pub fn from_payload(payload: &Value) -> Result<Self, String> { + Self::from_payload_for_mode(payload, HttpMode::Safe) + } + + pub fn from_payload_for_mode(payload: &Value, mode: HttpMode) -> Result<Self, String> { + if mode == HttpMode::Compat && !cfg!(feature = "scrapling-compat") { + return Err( + "browser::fetch compat HTTP engine is not compiled into this binary; rebuild the Tier-1 target with feature `scrapling-compat` and the certified curl-impersonate artifacts" + .to_string(), + ); + } + if mode == HttpMode::Safe { + let reject = |field: &str, enabled: bool, reason: &str| -> Result<(), String> { + if enabled { + Err(format!( + "safe mode refuses `{field}`: {reason}; use a certified compat build or remove the option" + )) + } else { + Ok(()) + } + }; + if let Some(value) = payload.get("impersonate").filter(|value| !value.is_null()) { + let value = value.as_str().ok_or("impersonate must be a string")?; + reject( + "impersonate", + !matches!(value, "" | "chrome"), + "the safe engine implements only its bounded Chrome header profile", + )?; + } + reject( + "http3", + payload + .get("http3") + .and_then(Value::as_bool) + .unwrap_or(false), + "the safe reqwest engine has no certified HTTP/3 transport", + )?; + reject( + "verify:false", + payload.get("verify").and_then(Value::as_bool) == Some(false), + "TLS certificate verification cannot be disabled", + )?; + reject( + "proxy", + payload + .get("proxy") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()), + "a caller proxy can resolve or route to addresses outside the egress policy", + )?; + reject( + "proxies", + payload + .get("proxies") + .and_then(Value::as_object) + .is_some_and(|value| !value.is_empty()), + "per-scheme proxies bypass address pinning", + )?; + reject( + "proxy_auth", + payload.get("proxy_auth").is_some_and(|v| !v.is_null()), + "proxy authentication is unavailable when caller proxies are refused", + )?; + reject( + "stealthy_headers:true", + payload.get("stealthy_headers").and_then(Value::as_bool) == Some(true), + "the safe reqwest engine cannot reproduce BrowserForge's generated header fingerprint", + )?; + } + let method = payload + .get("method") + .and_then(Value::as_str) + .unwrap_or("get") + .to_ascii_lowercase(); + if !matches!(method.as_str(), "get" | "post" | "put" | "delete") { + return Err(format!("unsupported method: {method}")); + } + // `timeout` is SECONDS on this tier (it is milliseconds on the browser + // tiers — the schemas say so explicitly, and the two disagree on + // purpose because the underlying libraries do). + // + // The upper clamp is not cosmetic: `Duration::from_secs_f64` PANICS on + // a value it cannot represent, and the schema declares a bare + // `number`, so `{"timeout": 1e20}` would panic inside the handler. + // The SDK spawns handlers detached, so that panic would drop the + // invocation entirely and hang the caller with no result. + let timeout = payload + .get("timeout") + .and_then(Value::as_f64) + .filter(|value| value.is_finite()) + .unwrap_or(30.0); + let compat_timeout_ms = (timeout * 1_000.0) as i64; + let timeout = if mode == HttpMode::Safe { + if timeout > 0.0 { + timeout.min(MAX_TIMEOUT_SECS) + } else { + 30.0 + } + } else { + timeout.clamp(0.0, MAX_TIMEOUT_SECS) + }; + let raw_retry_delay = payload + .get("retry_delay") + .and_then(Value::as_f64) + .filter(|value| value.is_finite()) + .unwrap_or(1.0); + let retry_delay_negative = raw_retry_delay < 0.0; + let retry_delay = if mode == HttpMode::Safe { + raw_retry_delay.clamp(0.0, MAX_RETRY_DELAY_SECS) + } else { + raw_retry_delay.clamp(0.0, (u64::MAX / 2) as f64) + }; + let params = params_pairs(payload); + let body = match (payload.get("json"), payload.get("data")) { + (Some(j), _) if !j.is_null() => Some(Body::Json(j.clone())), + (_, Some(d)) if !d.is_null() => Some(Body::Form(d.clone())), + _ => None, + }; + let raw_max_redirects = payload + .get("max_redirects") + .and_then(Value::as_i64) + .unwrap_or(30); + if mode == HttpMode::Safe && raw_max_redirects < 0 { + return Err(format!( + "safe mode refuses `max_redirects:{raw_max_redirects}`: unlimited or invalid redirect counts exceed the bounded request policy; use a non-negative limit" + )); + } + let explicit_impersonate = payload + .get("impersonate") + .and_then(Value::as_str) + .map(str::to_owned); + if mode == HttpMode::Compat + && payload + .get("proxy") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()) + && payload + .get("proxies") + .and_then(Value::as_object) + .is_some_and(|value| !value.is_empty()) + { + return Err("Cannot specify both 'proxy' and 'proxies'".to_string()); + } + let impersonate = if mode == HttpMode::Compat { + explicit_impersonate.or_else(|| Some("chrome".to_string())) + } else { + None + }; + let compat_default_ua = (mode == HttpMode::Compat) + .then(crate::scrapling::browserforge::default_user_agent) + .transpose()?; + let impersonate = impersonate.filter(|value| !value.is_empty()); + let headers = stealthy_headers( + payload, + mode, + impersonate.is_some(), + compat_default_ua.as_deref(), + )?; + Ok(Self { + mode, + method, + timeout: Duration::from_secs_f64(timeout), + compat_timeout_ms, + follow_redirects: payload + .get("follow_redirects") + .and_then(Value::as_bool) + .unwrap_or(true), + compat_follow_redirects: match payload.get("follow_redirects").and_then(Value::as_bool) + { + Some(true) => 1, + Some(false) => 0, + None => 4, + }, + max_redirects: raw_max_redirects, + retries: payload.get("retries").and_then(Value::as_i64).unwrap_or(3), + retry_delay: Duration::from_secs_f64(retry_delay), + retry_delay_negative, + proxy: payload + .get("proxy") + .and_then(Value::as_str) + .filter(|p| !p.is_empty()) + .map(str::to_string), + proxies: str_pairs(payload, "proxies"), + proxy_auth: pair(payload, "proxy_auth")?, + auth: pair(payload, "auth")?, + impersonate, + http3: payload + .get("http3") + .and_then(Value::as_bool) + .unwrap_or(false), + verify: payload + .get("verify") + .and_then(Value::as_bool) + .unwrap_or(true), + headers, + cookies: str_pairs(payload, "cookies"), + params, + body, + jar: None, + #[cfg(feature = "scrapling-compat")] + compat_session: None, + }) + } + + fn method_allows_body(&self) -> bool { + self.method != "get" + } +} + +/// Caller headers, with browser-ish defaults filled in underneath unless +/// `stealthy_headers: false`. Caller-supplied values always win. +fn stealthy_headers( + payload: &Value, + mode: HttpMode, + impersonation_enabled: bool, + compat_default_ua: Option<&str>, +) -> Result<Vec<(String, String)>, String> { + let mut headers = str_pairs(payload, "headers"); + let stealth = payload + .get("stealthy_headers") + .and_then(Value::as_bool) + .unwrap_or(true); + let has = |h: &[(String, String)], name: &str| { + h.iter().any(|(key, _)| key.eq_ignore_ascii_case(name)) + }; + if !stealth { + if mode == HttpMode::Compat && !impersonation_enabled && !has(&headers, "user-agent") { + headers.push(( + "User-Agent".into(), + compat_default_ua.unwrap_or(DEFAULT_UA).into(), + )); + } + return Ok(headers); + } + if mode == HttpMode::Compat && impersonation_enabled { + if !has(&headers, "referer") { + headers.push(("referer".into(), "https://www.google.com/".into())); + } + } else if mode == HttpMode::Compat { + let supplied = headers + .iter() + .map(|(name, _)| name.to_ascii_lowercase()) + .collect::<std::collections::HashSet<_>>(); + if !supplied.contains("referer") { + headers.push(("referer".into(), "https://www.google.com/".into())); + } + for (name, value) in crate::scrapling::browserforge::generate_http_headers()? { + if !supplied.contains(&name.to_ascii_lowercase()) { + headers.push((name, value)); + } + } + } else { + if !has(&headers, "user-agent") { + headers.push(("user-agent".into(), DEFAULT_UA.into())); + } + if !has(&headers, "accept") { + headers.push(("accept".into(), DEFAULT_ACCEPT.into())); + } + if !has(&headers, "accept-language") { + headers.push(("accept-language".into(), "en-US,en;q=0.9".into())); + } + } + Ok(headers) +} + +fn build_client( + hostname: &str, + address: SocketAddr, + opts: &HttpOptions, +) -> Result<reqwest::Client, String> { + let mut b = reqwest::Client::builder() + // Manual redirects: every hop is re-validated (see module docs). + .redirect(reqwest::redirect::Policy::none()) + .pool_max_idle_per_host(0) + .timeout(opts.timeout); + if let Some(jar) = &opts.jar { + b = b.cookie_provider(jar.clone()); + } + match &opts.proxy { + // Through a proxy we cannot pin the socket — the proxy does its own + // resolution, so `.resolve()` would be ignored and the pin would be a + // false comfort. The proxy ENDPOINT itself is blocklist-checked + // separately (see `check_proxy`): it is the address this worker + // actually dials, so validating only the target would leave an + // internal proxy usable as a pivot into the private network. + Some(p) => { + b = b.proxy(reqwest::Proxy::all(p).map_err(|e| format!("invalid proxy: {e}"))?); + } + None => { + b = b.resolve(hostname, address); + } + } + b.build().map_err(|e| e.to_string()) +} + +fn charset_of(headers: &reqwest::header::HeaderMap) -> Option<String> { + let ct = headers.get(reqwest::header::CONTENT_TYPE)?.to_str().ok()?; + ct.split(';') + .filter_map(|p| p.split_once('=')) + .find(|(k, _)| k.trim().eq_ignore_ascii_case("charset")) + .map(|(_, v)| v.trim().trim_matches('"').to_ascii_lowercase()) +} + +#[cfg(feature = "scrapling-compat")] +fn charset_of_raw(headers: &Map<String, Value>) -> Option<String> { + let content_type = headers.get("content-type")?.as_str()?; + content_type + .split(';') + .filter_map(|part| part.split_once('=')) + .find(|(name, _)| name.trim().eq_ignore_ascii_case("charset")) + .map(|(_, value)| value.trim().trim_matches('"').to_ascii_lowercase()) +} + +fn decode_body(bytes: &[u8], encoding: Option<&str>) -> String { + let decoder = encoding + .and_then(|label| encoding_rs::Encoding::for_label(label.as_bytes())) + .unwrap_or(encoding_rs::UTF_8); + decoder.decode(bytes).0.into_owned() +} + +fn checked_body_len(current: usize, incoming: usize) -> Option<usize> { + current + .checked_add(incoming) + .filter(|total| *total <= MAX_BODY_BYTES) +} + +async fn bounded_body(mut response: reqwest::Response, url: &str) -> Result<Vec<u8>, String> { + if let Some(len) = response.content_length() { + if len > MAX_BODY_BYTES as u64 { + return Err(format!( + "response body is {len} bytes, over the {MAX_BODY_BYTES}-byte cap ({url})" + )); + } + } + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| format!("reading body of {url}: {error}"))? + { + if checked_body_len(body.len(), chunk.len()).is_none() { + return Err(format!( + "response body exceeded the {MAX_BODY_BYTES}-byte cap while streaming ({url})" + )); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +fn flatten_headers(headers: &reqwest::header::HeaderMap) -> Map<String, Value> { + // Repeated headers (Set-Cookie, Vary, Link, ...) are joined with ", ", + // matching what the compat/curl engine produces — last-value-wins would + // silently drop data. + let mut out = Map::new(); + for k in headers.keys() { + let joined = headers + .get_all(k) + .iter() + .filter_map(|v| v.to_str().ok()) + .collect::<Vec<_>>() + .join(", "); + out.insert(k.as_str().to_string(), json!(joined)); + } + out +} + +/// Cookies the response set, as a flat name -> value map. +fn response_cookies(headers: &reqwest::header::HeaderMap) -> Map<String, Value> { + let mut out = Map::new(); + for v in headers.get_all(reqwest::header::SET_COOKIE) { + let Ok(s) = v.to_str() else { continue }; + let pair = s.split(';').next().unwrap_or(""); + if let Some((name, value)) = pair.split_once('=') { + out.insert(name.trim().to_string(), json!(value.trim())); + } + } + out +} + +/// Validate a caller-supplied proxy endpoint against the same blocklist the +/// targets go through. When a proxy is configured it — not the target — is +/// what this worker connects to, so skipping this check would let +/// `{"proxy": "http://10.0.0.5:3128"}` reach straight into the private +/// network while the target check looked at an unrelated public host. +/// +/// `parse_target` only admits http/https, which also rejects `socks5h://…` +/// (whose whole point is proxy-side DNS we cannot inspect). +pub async fn check_proxy(opts: &HttpOptions, policy: &SsrfPolicy) -> Result<(), String> { + let Some(raw) = &opts.proxy else { + return Ok(()); + }; + let target = + parse_target(raw).map_err(|e| format!("proxy is not a usable http(s) endpoint: {e}"))?; + check_target(&target, policy) + .await + .map_err(|r| format!("proxy refused: {}", r.message))?; + Ok(()) +} + +/// One attempt: walk the redirect chain, SSRF-checking every hop. +async fn attempt( + url: &str, + opts: &HttpOptions, + policy: &SsrfPolicy, + deadline: std::time::Instant, +) -> Result<PageData, String> { + let mut current = url.to_string(); + let mut method = opts.method.clone(); + let mut send_body = opts.method_allows_body(); + let mut cookies = Map::new(); + // Cookies set by responses along the chain, replayed host-only: a cookie + // is only sent back to the exact host that set it. That covers + // login/consent flows that bounce through the same host without ever + // leaking a value cross-host. + let mut hop_cookies: std::collections::HashMap<String, Map<String, Value>> = + std::collections::HashMap::new(); + let origin = parse_target(url)?.url.clone(); + // Schema declares a bare integer; an oversized value must clamp, not + // panic in try_from. Negative is rejected at parse for safe mode. + let max_redirects = opts.max_redirects.clamp(0, MAX_REDIRECTS) as u32; + for hop in 0..=max_redirects { + if std::time::Instant::now() >= deadline { + return Err(format!( + "exceeded the total budget while following redirects (hop {hop}) fetching {url}" + )); + } + let target = parse_target(&current)?; + // Cross-origin hop: drop credentials the caller scoped to the origin + // they addressed. An open redirect on a trusted host would otherwise + // hand an Authorization bearer to whoever it points at. + let cross_origin = !same_origin(&origin, &target.url); + let resolved = check_target(&target, policy).await.map_err(|r| r.message)?; + let client = build_client( + &target.hostname, + SocketAddr::new(resolved.address, resolved.port), + opts, + )?; + + let request_method = reqwest::Method::from_bytes(method.to_uppercase().as_bytes()) + .map_err(|e| e.to_string())?; + let mut req = client.request(request_method, target.url.clone()); + for (k, v) in &opts.headers { + if cross_origin && is_sensitive_header(k) { + continue; + } + req = req.header(k, v); + } + let mut jar = Map::new(); + // A manual Cookie header makes reqwest skip its cookie store for the + // request, so when a session jar exists its cookies must be merged in + // here or they would be silently suppressed by per-request cookies. + if let Some(session_jar) = &opts.jar { + use reqwest::cookie::CookieStore; + if let Some(header) = session_jar.cookies(&target.url) { + if let Ok(s) = header.to_str() { + for pair in s.split("; ") { + if let Some((k, v)) = pair.split_once('=') { + jar.insert(k.to_string(), json!(v)); + } + } + } + } + } + if !cross_origin { + for (k, v) in &opts.cookies { + jar.insert(k.clone(), json!(v)); + } + } + // Server-set cookies from earlier hops to this same host override the + // caller's on a name collision — that's what a cookie jar does. + if let Some(set) = hop_cookies.get(&target.hostname) { + for (k, v) in set { + jar.insert(k.clone(), v.clone()); + } + } + if !jar.is_empty() { + let jar = jar + .iter() + .map(|(k, v)| format!("{k}={}", v.as_str().unwrap_or_default())) + .collect::<Vec<_>>() + .join("; "); + req = req.header(reqwest::header::COOKIE, jar); + } + if hop == 0 && !opts.params.is_empty() { + req = req.query(&opts.params); + } + if !cross_origin { + if let Some((username, password)) = &opts.auth { + req = req.basic_auth(username, Some(password)); + } + } + // The wrapper forwards data/json for POST, PUT, and DELETE. DELETE + // bodies are unusual but explicitly supported by Scrapling. + if send_body { + match &opts.body { + Some(Body::Json(v)) => req = req.json(v), + Some(Body::Form(v)) => { + let form: Vec<(String, String)> = v + .as_object() + .map(|m| { + m.iter() + .map(|(k, val)| { + let s = match val { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + (k.clone(), s) + }) + .collect::<Vec<_>>() + }) + .unwrap_or_default(); + req = req.form(&form); + } + None => {} + } + } + + let resp = req.send().await.map_err(|e| { + if e.is_timeout() { + format!("timeout after {:?} fetching {current}", opts.timeout) + } else { + format!("transport error fetching {current}: {e}") + } + })?; + + let status = resp.status(); + let headers = resp.headers().clone(); + for (k, v) in response_cookies(&headers) { + hop_cookies + .entry(target.hostname.clone()) + .or_default() + .insert(k.clone(), v.clone()); + cookies.insert(k, v); + } + + if opts.follow_redirects && status.is_redirection() { + if let Some(loc) = headers + .get(reqwest::header::LOCATION) + .and_then(|l| l.to_str().ok()) + { + (method, send_body) = redirected_request(status.as_u16(), &method, send_body); + current = target + .url + .join(loc) + .map_err(|e| format!("bad redirect target {loc}: {e}"))? + .to_string(); + continue; + } + } + + let encoding = charset_of(&headers); + let final_url = resp.url().to_string(); + let body = bounded_body(resp, &current).await?; + let html = decode_body(&body, encoding.as_deref()); + return Ok(PageData { + status: Some(status.as_u16()), + url: final_url, + headers: flatten_headers(&headers), + cookies, + encoding, + html, + captured_xhr: vec![], + }); + } + Err(format!( + "too many redirects (>{}) starting at {url}", + max_redirects + )) +} + +/// Fetch one URL, retrying transport failures. HTTP error statuses are NOT +/// retried — a 404 is an answer, and re-asking produces the same 404 while +/// costing the caller their timeout budget. +pub async fn fetch_page( + url: &str, + opts: &HttpOptions, + policy: &SsrfPolicy, +) -> Result<PageData, String> { + if opts.mode == HttpMode::Compat { + #[cfg(feature = "scrapling-compat")] + { + if let Some(session) = opts.compat_session.clone() { + return curl_compat::fetch_page_with_session( + url.to_string(), + opts.clone(), + session, + ) + .await; + } + return curl_compat::fetch_page(url.to_string(), opts.clone()).await; + } + #[cfg(not(feature = "scrapling-compat"))] + { + return Err( + "browser::fetch compat HTTP engine is not compiled into this binary".to_string(), + ); + } + } + check_proxy(opts, policy).await?; + // `opts.timeout` is per REQUEST; across redirect hops and retries it + // multiplies out (3 retries x 31 hops x 30s ≈ 46 minutes by default). + // The reference hands `timeout` to curl's CURLOPT_TIMEOUT, which is a + // total budget, so bound the whole call too. + let budget = opts.timeout * TOTAL_BUDGET_MULTIPLIER; + let started = std::time::Instant::now(); + let deadline = started + budget; + let mut last = String::new(); + let attempts = opts.retries.max(0) as u32; + for i in 0..attempts { + if started.elapsed() >= budget { + return Err(if last.is_empty() { + format!("exceeded the total budget of {budget:?} fetching {url}") + } else { + last + }); + } + match attempt(url, opts, policy, deadline).await { + Ok(page) => return Ok(page), + Err(e) => { + // A refused host is a verdict, not a flake: retrying just + // repeats the same DNS answer and the same rejection. + if e.contains("refusing to dial") + || e.contains("is in ") + || e.starts_with("scheme not allowed") + || e.starts_with("url is not a valid") + || e.starts_with("unsupported method") + { + return Err(e); + } + last = e; + if i + 1 < attempts && !opts.retry_delay.is_zero() { + tokio::time::sleep(opts.retry_delay).await; + } + } + } + } + if attempts == 0 { + Err("No active session available.".to_string()) + } else { + Err(last) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc; + + fn options_with_proxy_for_validation(raw: &str) -> HttpOptions { + let mut options = HttpOptions::from_payload(&json!({})).unwrap(); + options.proxy = Some(raw.to_string()); + options + } + + fn read_request(stream: &mut std::net::TcpStream) -> String { + let mut request = Vec::new(); + let mut chunk = [0u8; 4096]; + let mut expected = None; + loop { + let read = stream.read(&mut chunk).unwrap(); + request.extend_from_slice(&chunk[..read]); + if expected.is_none() { + if let Some(end) = request.windows(4).position(|part| part == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&request[..end]); + let length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .and_then(|value| value.trim().parse::<usize>().ok()) + }) + .unwrap_or(0); + expected = Some(end + 4 + length); + } + } + if read == 0 || expected.is_some_and(|length| request.len() >= length) { + break; + } + } + String::from_utf8(request).unwrap() + } + + fn safe_server() -> (String, mpsc::Receiver<String>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + sender.send(read_request(&mut stream)).unwrap(); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n2\r\nhe\r\n3\r\nllo\r\n0\r\n\r\n", + ) + .unwrap(); + }); + (format!("http://{address}/safe"), receiver) + } + + #[test] + fn method_defaults_to_get_and_rejects_unknown() { + assert_eq!(HttpOptions::from_payload(&json!({})).unwrap().method, "get"); + assert_eq!( + HttpOptions::from_payload(&json!({"method": "POST"})) + .unwrap() + .method, + "post" + ); + assert_eq!( + HttpOptions::from_payload(&json!({"method": "patch"})).unwrap_err(), + "unsupported method: patch" + ); + } + + #[test] + fn safe_mode_refuses_network_options_it_cannot_enforce() { + let cases = [ + (json!({"http3": true}), "http3"), + (json!({"verify": false}), "verify:false"), + (json!({"proxy": "http://proxy.test"}), "proxy"), + ( + json!({"proxies": {"https": "http://proxy.test"}}), + "proxies", + ), + (json!({"proxy_auth": ["u", "p"]}), "proxy_auth"), + (json!({"stealthy_headers": true}), "stealthy_headers:true"), + ]; + for (payload, option) in cases { + let error = HttpOptions::from_payload_for_mode(&payload, HttpMode::Safe).unwrap_err(); + assert!(error.contains(option), "{option}: {error}"); + } + assert!(HttpOptions::from_payload_for_mode( + &json!({"impersonate": "chrome"}), + HttpMode::Safe + ) + .is_ok()); + assert!(HttpOptions::from_payload_for_mode( + &json!({"impersonate": "firefox"}), + HttpMode::Safe + ) + .unwrap_err() + .contains("impersonate")); + } + + #[test] + fn ordered_fields_and_basic_auth_are_preserved() { + let options = HttpOptions::from_payload_for_mode( + &json!({ + "headers": {"x-second": "2", "x-first": "1"}, + "params": {"z": "last", "a": [1, 2]}, + "cookies": {"second": "2", "first": "1"}, + "auth": ["user", "pass"] + }), + HttpMode::Safe, + ) + .unwrap(); + assert_eq!(options.headers[0], ("x-second".into(), "2".into())); + assert_eq!(options.headers[1], ("x-first".into(), "1".into())); + assert_eq!(options.params[0], ("z".into(), "last".into())); + assert_eq!(options.params[1], ("a".into(), "1".into())); + assert_eq!(options.params[2], ("a".into(), "2".into())); + assert_eq!(options.cookies[0], ("second".into(), "2".into())); + assert_eq!(options.auth, Some(("user".into(), "pass".into()))); + } + + #[test] + fn params_match_curl_cffi_json_and_doseq_coercion() { + let options = HttpOptions::from_payload(&json!({ + "params": { + "bool": true, + "none": null, + "obj": {"a": 1}, + "arr": [true, null, {"z": 2}] + } + })) + .unwrap(); + assert_eq!( + options.params, + vec![ + ("bool".into(), "true".into()), + ("none".into(), "None".into()), + ("obj".into(), r#"{"a": 1}"#.into()), + ("arr".into(), "True".into()), + ("arr".into(), "None".into()), + ("arr".into(), "{'z': 2}".into()), + ] + ); + } + + #[test] + fn delete_accepts_the_same_body_inputs_as_post_and_put() { + let options = HttpOptions::from_payload_for_mode( + &json!({"method": "delete", "json": {"delete": true}}), + HttpMode::Safe, + ) + .unwrap(); + assert!(matches!(options.body, Some(Body::Json(_)))); + assert!(options.method_allows_body()); + } + + #[test] + fn safe_refuses_unlimited_redirects_while_compat_preserves_minus_one() { + let error = + HttpOptions::from_payload_for_mode(&json!({"max_redirects": -1}), HttpMode::Safe) + .unwrap_err(); + assert!(error.contains("max_redirects:-1"), "{error}"); + + let options = + HttpOptions::from_payload_for_mode(&json!({"max_redirects": -1}), HttpMode::Compat); + if cfg!(feature = "scrapling-compat") { + assert_eq!(options.unwrap().max_redirects, -1); + } else { + assert!(options.unwrap_err().contains("not compiled")); + } + } + + #[cfg(feature = "scrapling-compat")] + #[test] + fn compat_option_errors_match_the_wrapper_oracle() { + assert_eq!( + HttpOptions::from_payload_for_mode( + &json!({"proxy": "http://a", "proxies": {"http": "http://b"}}), + HttpMode::Compat, + ) + .unwrap_err(), + "Cannot specify both 'proxy' and 'proxies'" + ); + assert_eq!( + HttpOptions::from_payload_for_mode(&json!({"auth": ["u"]}), HttpMode::Compat) + .unwrap_err(), + "not enough values to unpack (expected 2, got 1)" + ); + } + + #[test] + fn timeout_is_seconds_on_this_tier() { + let o = HttpOptions::from_payload(&json!({"timeout": 2.5})).unwrap(); + assert_eq!(o.timeout, Duration::from_millis(2500)); + // default 30s, and a nonsense value falls back rather than becoming 0 + assert_eq!( + HttpOptions::from_payload(&json!({})).unwrap().timeout, + Duration::from_secs(30) + ); + assert_eq!( + HttpOptions::from_payload(&json!({"timeout": 0})) + .unwrap() + .timeout, + Duration::from_secs(30) + ); + } + + #[test] + fn retries_preserve_zero_attempt_quirk() { + assert_eq!(HttpOptions::from_payload(&json!({})).unwrap().retries, 3); + assert_eq!( + HttpOptions::from_payload(&json!({"retries": 0})) + .unwrap() + .retries, + 0 + ); + } + + #[cfg(feature = "scrapling-compat")] + #[test] + fn compat_preserves_signed_curl_values_and_retry_quirks() { + let timeout = HttpOptions::from_payload_for_mode( + &json!({"timeout": -1, "retries": 1, "stealthy_headers": false}), + HttpMode::Compat, + ) + .unwrap(); + assert_eq!(timeout.compat_timeout_ms, -1000); + + let redirects = HttpOptions::from_payload_for_mode( + &json!({"max_redirects": -2, "retries": 1, "stealthy_headers": false}), + HttpMode::Compat, + ) + .unwrap(); + assert_eq!(redirects.max_redirects, -2); + + let retries = HttpOptions::from_payload_for_mode( + &json!({"retries": -1, "stealthy_headers": false}), + HttpMode::Compat, + ) + .unwrap(); + assert_eq!(retries.retries, -1); + + let delay = HttpOptions::from_payload_for_mode( + &json!({"retry_delay": -1, "stealthy_headers": false}), + HttpMode::Compat, + ) + .unwrap(); + assert!(delay.retry_delay_negative); + } + + #[cfg(feature = "scrapling-compat")] + #[test] + fn compat_does_not_silently_replace_explicit_empty_impersonation() { + let generated = + HttpOptions::from_payload_for_mode(&json!({"impersonate": ""}), HttpMode::Compat) + .unwrap(); + assert_eq!(generated.impersonate, None); + assert!(generated + .headers + .iter() + .any(|(name, value)| name == "referer" && value == "https://www.google.com/")); + assert!(generated + .headers + .iter() + .any(|(name, value)| name == "User-Agent" && value.contains("Mozilla/5.0"))); + + let without_stealth = HttpOptions::from_payload_for_mode( + &json!({"impersonate": "", "stealthy_headers": false}), + HttpMode::Compat, + ) + .unwrap(); + assert_eq!(without_stealth.impersonate, None); + assert_eq!( + without_stealth.headers, + vec![( + "User-Agent".into(), + crate::scrapling::browserforge::default_user_agent().unwrap() + )] + ); + } + + #[test] + fn stealthy_headers_fill_defaults_but_never_override_caller() { + let o = HttpOptions::from_payload(&json!({"headers": {"User-Agent": "mine"}})).unwrap(); + assert_eq!(o.headers[0], ("User-Agent".into(), "mine".into())); + assert!( + !o.headers.iter().any(|(name, _)| name == "user-agent"), + "must not add a second, case-variant UA header" + ); + assert!(o.headers.iter().any(|(name, _)| name == "accept")); + + let off = HttpOptions::from_payload(&json!({"stealthy_headers": false})).unwrap(); + assert!(off.headers.is_empty()); + } + + #[test] + fn json_body_wins_over_data_and_only_on_post_put() { + let o = HttpOptions::from_payload(&json!({"json": {"a": 1}, "data": {"b": 2}})).unwrap(); + assert!(matches!(o.body, Some(Body::Json(_)))); + } + + #[test] + fn charset_parsed_from_content_type() { + let mut h = reqwest::header::HeaderMap::new(); + h.insert( + reqwest::header::CONTENT_TYPE, + "text/html; charset=ISO-8859-1".parse().unwrap(), + ); + assert_eq!(charset_of(&h).as_deref(), Some("iso-8859-1")); + assert_eq!(charset_of(&reqwest::header::HeaderMap::new()), None); + } + + #[test] + fn streamed_body_limit_is_checked_even_without_content_length() { + assert_eq!( + checked_body_len(MAX_BODY_BYTES - 1, 1), + Some(MAX_BODY_BYTES) + ); + assert_eq!(checked_body_len(MAX_BODY_BYTES, 1), None); + assert_eq!(checked_body_len(usize::MAX, 1), None); + } + + #[test] + fn set_cookie_headers_flatten_to_name_value() { + let mut h = reqwest::header::HeaderMap::new(); + h.append( + reqwest::header::SET_COOKIE, + "sid=abc; Path=/; HttpOnly".parse().unwrap(), + ); + h.append( + reqwest::header::SET_COOKIE, + "theme=dark; Max-Age=60".parse().unwrap(), + ); + let c = response_cookies(&h); + assert_eq!(c.get("sid").unwrap(), &json!("abc")); + assert_eq!(c.get("theme").unwrap(), &json!("dark")); + } + + #[test] + fn absurd_durations_are_clamped_not_panicked_on() { + // `Duration::from_secs_f64` panics on an unrepresentable value, and + // the SDK spawns handlers detached — so a panic here would drop the + // invocation and hang the caller with no result at all. + for v in [1e20, f64::MAX, 1e308] { + let o = HttpOptions::from_payload(&json!({"timeout": v, "retry_delay": v})).unwrap(); + assert!(o.timeout <= Duration::from_secs_f64(MAX_TIMEOUT_SECS)); + assert!(o.retry_delay <= Duration::from_secs_f64(MAX_RETRY_DELAY_SECS)); + } + // NaN and infinity fall back to the default rather than clamping. + let o = HttpOptions::from_payload(&json!({"timeout": f64::NAN})).unwrap(); + assert_eq!(o.timeout, Duration::from_secs(30)); + } + + #[test] + fn sensitive_headers_are_recognised_case_insensitively() { + for h in [ + "Authorization", + "authorization", + "COOKIE", + "Proxy-Authorization", + ] { + assert!(is_sensitive_header(h), "{h} must be treated as sensitive"); + } + for h in ["accept", "user-agent", "x-custom"] { + assert!(!is_sensitive_header(h)); + } + } + + #[test] + fn same_origin_compares_scheme_host_and_effective_port() { + let u = |s: &str| url::Url::parse(s).unwrap(); + assert!(same_origin(&u("https://a.test/x"), &u("https://a.test/y"))); + // default port is the same origin as the explicit one + assert!(same_origin( + &u("https://a.test/"), + &u("https://a.test:443/") + )); + assert!(!same_origin(&u("https://a.test/"), &u("http://a.test/"))); + assert!(!same_origin(&u("https://a.test/"), &u("https://b.test/"))); + assert!(!same_origin( + &u("https://a.test/"), + &u("https://a.test:8443/") + )); + } + + #[test] + fn redirect_method_and_body_rules_match_curl() { + for status in 301..=303 { + assert_eq!( + redirected_request(status, "post", true), + ("get".into(), false) + ); + for method in ["put", "delete"] { + assert_eq!( + redirected_request(status, method, true), + (method.into(), false) + ); + } + } + for status in [307, 308] { + for method in ["post", "put", "delete"] { + assert_eq!( + redirected_request(status, method, true), + (method.into(), true) + ); + } + } + } + + #[tokio::test] + async fn cross_origin_redirect_drops_credentials_body_and_initial_query() { + let destination = TcpListener::bind("127.0.0.1:0").unwrap(); + let destination_address = destination.local_addr().unwrap(); + let origin = TcpListener::bind("127.0.0.1:0").unwrap(); + let origin_address = origin.local_addr().unwrap(); + let (sender, requests) = mpsc::channel(); + + let first_sender = sender.clone(); + std::thread::spawn(move || { + let (mut stream, _) = origin.accept().unwrap(); + first_sender.send(read_request(&mut stream)).unwrap(); + write!( + stream, + "HTTP/1.1 302 Found\r\nLocation: http://{destination_address}/end\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .unwrap(); + }); + std::thread::spawn(move || { + let (mut stream, _) = destination.accept().unwrap(); + sender.send(read_request(&mut stream)).unwrap(); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + .unwrap(); + }); + + let options = HttpOptions::from_payload(&json!({ + "method": "post", + "data": {"a": "b"}, + "params": {"q": 1}, + "auth": ["u", "p"], + "cookies": {"sid": "x"}, + "headers": {"X-Test": "kept"}, + "follow_redirects": true, + "retries": 1, + "stealthy_headers": false + })) + .unwrap(); + let page = fetch_page( + &format!("http://{origin_address}/start"), + &options, + &SsrfPolicy { + allow_loopback: true, + }, + ) + .await + .unwrap(); + + let first = requests.recv().unwrap(); + assert!(first.starts_with("POST /start?q=1 HTTP/1.1\r\n"), "{first}"); + assert!(first + .to_ascii_lowercase() + .contains("authorization: basic dtpw")); + assert!(first.to_ascii_lowercase().contains("cookie: sid=x")); + assert!(first.ends_with("a=b"), "{first}"); + + let second = requests.recv().unwrap(); + let lower = second.to_ascii_lowercase(); + assert!(second.starts_with("GET /end HTTP/1.1\r\n"), "{second}"); + assert!(!lower.contains("authorization:"), "{second}"); + assert!(!lower.contains("cookie:"), "{second}"); + assert!(lower.contains("x-test: kept"), "{second}"); + assert!(!second.contains("?q=1"), "{second}"); + assert_eq!(page.html, "ok"); + } + + #[tokio::test] + async fn redirect_to_metadata_is_refused_before_the_second_request() { + let origin = TcpListener::bind("127.0.0.1:0").unwrap(); + let origin_address = origin.local_addr().unwrap(); + std::thread::spawn(move || { + let (mut stream, _) = origin.accept().unwrap(); + let _ = read_request(&mut stream); + stream + .write_all( + b"HTTP/1.1 302 Found\r\nLocation: http://169.254.169.254/latest/meta-data/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .unwrap(); + }); + let options = HttpOptions::from_payload(&json!({ + "follow_redirects": true, + "retries": 3, + "retry_delay": 0, + "stealthy_headers": false + })) + .unwrap(); + let error = fetch_page( + &format!("http://{origin_address}/redirect"), + &options, + &SsrfPolicy { + allow_loopback: true, + }, + ) + .await + .unwrap_err(); + assert!(error.contains("link-local"), "{error}"); + } + + #[tokio::test] + async fn a_private_proxy_is_refused_before_any_request() { + // The proxy is the address this worker actually dials, so validating + // only the (public) target would let an internal proxy be used as a + // pivot into the private network. + let opts = options_with_proxy_for_validation("http://169.254.169.254:3128"); + let err = check_proxy( + &opts, + &SsrfPolicy { + allow_loopback: false, + }, + ) + .await + .unwrap_err(); + assert!(err.starts_with("proxy refused:"), "got: {err}"); + assert!(err.contains("link-local"), "got: {err}"); + } + + #[tokio::test] + async fn a_socks_proxy_is_refused_because_its_dns_is_uninspectable() { + let opts = options_with_proxy_for_validation("socks5h://127.0.0.1:1080"); + let err = check_proxy( + &opts, + &SsrfPolicy { + allow_loopback: true, + }, + ) + .await + .unwrap_err(); + assert!(err.contains("not a usable http(s) endpoint"), "got: {err}"); + } + + #[tokio::test] + async fn invalid_proxy_errors_do_not_echo_credentials() { + let raw = "http://user:pass@["; + let opts = options_with_proxy_for_validation(raw); + let check_err = check_proxy( + &opts, + &SsrfPolicy { + allow_loopback: true, + }, + ) + .await + .unwrap_err(); + assert!( + !check_err.contains("user:pass"), + "credentials leaked: {check_err}" + ); + assert!( + check_err.contains("not a usable http(s) endpoint"), + "got: {check_err}" + ); + + let build_err = + match build_client("example.com", SocketAddr::from(([1, 1, 1, 1], 443)), &opts) { + Ok(_) => panic!("invalid proxy unexpectedly built a client"), + Err(error) => error, + }; + assert!( + !build_err.contains("user:pass"), + "credentials leaked: {build_err}" + ); + assert!(build_err.contains("invalid proxy"), "got: {build_err}"); + } + + #[tokio::test] + async fn no_proxy_configured_is_not_an_error() { + let opts = HttpOptions::from_payload(&json!({})).unwrap(); + assert!(check_proxy( + &opts, + &SsrfPolicy { + allow_loopback: false + } + ) + .await + .is_ok()); + } + + #[tokio::test] + async fn a_public_proxy_passes_the_check() { + let opts = options_with_proxy_for_validation("http://1.1.1.1:8080"); + assert!(check_proxy( + &opts, + &SsrfPolicy { + allow_loopback: false + } + ) + .await + .is_ok()); + } + + #[tokio::test] + async fn ssrf_rejection_is_not_retried_and_names_the_range() { + let opts = HttpOptions::from_payload(&json!({"retries": 3, "retry_delay": 0})).unwrap(); + let policy = SsrfPolicy { + allow_loopback: false, + }; + let err = fetch_page("http://169.254.169.254/latest/meta-data/", &opts, &policy) + .await + .unwrap_err(); + assert!(err.contains("link-local"), "got: {err}"); + } + + #[tokio::test] + async fn safe_engine_streams_unknown_length_and_sends_delete_body() { + let (url, request) = safe_server(); + let options = HttpOptions::from_payload(&json!({ + "method": "delete", + "json": {"delete": true}, + "stealthy_headers": false, + "retries": 1 + })) + .unwrap(); + let page = fetch_page( + &url, + &options, + &SsrfPolicy { + allow_loopback: true, + }, + ) + .await + .unwrap(); + let raw = request.recv().unwrap(); + assert!(raw.starts_with("DELETE /safe HTTP/1.1\r\n"), "{raw}"); + assert!(raw.ends_with("{\"delete\":true}"), "{raw}"); + assert_eq!(page.html, "hello"); + } + + #[tokio::test] + async fn fetches_a_real_page_over_the_wire() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0; 4096]; + let _ = stream.read(&mut request).unwrap(); + let body = b"<html><body><h1>Example Domain</h1></body></html>"; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .unwrap(); + stream.write_all(body).unwrap(); + }); + let url = format!("http://{address}/"); + let opts = HttpOptions::from_payload(&json!({"stealthy_headers": false})).unwrap(); + let page = fetch_page( + &url, + &opts, + &SsrfPolicy { + allow_loopback: true, + }, + ) + .await + .expect("hermetic origin should be reachable"); + + assert_eq!(page.status, Some(200)); + assert_eq!(page.url, url); + assert_eq!( + page.html, + "<html><body><h1>Example Domain</h1></body></html>" + ); + assert_eq!(page.headers["content-type"], "text/html; charset=utf-8"); + + // And the shared envelope on top of it: inline extraction + render. + let out = crate::scrapling::page::serialize_page( + &page, + &json!({"selectors": [{"name": "title", "css": "h1"}], "format": "text"}), + false, + ) + .unwrap(); + assert_eq!(out["extracted"]["title"], json!("Example Domain")); + assert_eq!(out["status"], json!(200)); + assert_eq!(out["content"], "Example Domain"); + } + + #[tokio::test] + async fn non_http_scheme_refused() { + let opts = HttpOptions::from_payload(&json!({})).unwrap(); + let err = fetch_page( + "file:///etc/passwd", + &opts, + &SsrfPolicy { + allow_loopback: true, + }, + ) + .await + .unwrap_err(); + assert!(err.contains("scheme not allowed"), "got: {err}"); + } +} diff --git a/browser/src/scrapling/fetch/curl_compat.rs b/browser/src/scrapling/fetch/curl_compat.rs new file mode 100644 index 000000000..30316b671 --- /dev/null +++ b/browser/src/scrapling/fetch/curl_compat.rs @@ -0,0 +1,961 @@ +use std::collections::HashMap; +use std::ffi::{c_char, c_long, c_void, CStr, CString}; +use std::ptr; +use std::sync::{Arc, Mutex, OnceLock}; + +use curl_impersonate_sys as curl; +use serde_json::{Map, Value}; + +use super::{charset_of_raw, python_scalar, Body, HttpOptions}; +use crate::scrapling::page::PageData; + +static GLOBAL: OnceLock<Result<(), String>> = OnceLock::new(); + +fn global_init() -> Result<(), String> { + GLOBAL + .get_or_init(|| { + let code = unsafe { curl::curl_global_init(curl::CURL_GLOBAL_DEFAULT) }; + if code == curl::CURLE_OK { + Ok(()) + } else { + Err(format!("curl_global_init failed with code {code}")) + } + }) + .clone() +} + +#[derive(Debug)] +struct Easy(*mut curl::CURL); + +// libcurl permits moving an easy handle between threads as long as only one +// thread uses it at a time. CompatSession's mutex provides that exclusion. +unsafe impl Send for Easy {} + +impl Easy { + fn new() -> Result<Self, String> { + global_init()?; + let handle = unsafe { curl::curl_easy_init() }; + if handle.is_null() { + Err("curl_easy_init returned null".to_string()) + } else { + Ok(Self(handle)) + } + } +} + +impl Drop for Easy { + fn drop(&mut self) { + unsafe { curl::curl_easy_cleanup(self.0) }; + } +} + +#[derive(Debug)] +struct SessionState { + easy: Easy, + cookies: Vec<String>, +} + +impl SessionState { + fn new() -> Result<Self, String> { + Ok(Self { + easy: Easy::new()?, + cookies: Vec::new(), + }) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct CompatSession(Arc<Mutex<SessionState>>); + +impl CompatSession { + pub(crate) fn new() -> Result<Self, String> { + SessionState::new().map(|state| Self(Arc::new(Mutex::new(state)))) + } +} + +struct Slist(*mut curl::curl_slist); + +impl Slist { + fn new() -> Self { + Self(ptr::null_mut()) + } + + fn append(&mut self, value: &CString) -> Result<(), String> { + let next = unsafe { curl::curl_slist_append(self.0, value.as_ptr()) }; + if next.is_null() { + Err("curl_slist_append ran out of memory".to_string()) + } else { + self.0 = next; + Ok(()) + } + } +} + +impl Drop for Slist { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { curl::curl_slist_free_all(self.0) }; + } + } +} + +#[derive(Default)] +struct Transfer { + body: Vec<u8>, + headers: Vec<u8>, +} + +unsafe extern "C" fn write_body( + data: *mut c_char, + size: usize, + count: usize, + userdata: *mut c_void, +) -> usize { + let Some(length) = size.checked_mul(count) else { + return 0; + }; + let transfer = &mut *(userdata.cast::<Transfer>()); + transfer + .body + .extend_from_slice(std::slice::from_raw_parts(data.cast::<u8>(), length)); + length +} + +unsafe extern "C" fn write_header( + data: *mut c_char, + size: usize, + count: usize, + userdata: *mut c_void, +) -> usize { + let Some(length) = size.checked_mul(count) else { + return 0; + }; + let transfer = &mut *(userdata.cast::<Transfer>()); + transfer + .headers + .extend_from_slice(std::slice::from_raw_parts(data.cast::<u8>(), length)); + length +} + +unsafe fn set_long(easy: &Easy, option: curl::CURLoption, value: c_long) -> Result<(), String> { + code(curl::curl_easy_setopt(easy.0, option, value)) +} + +unsafe fn set_ptr(easy: &Easy, option: curl::CURLoption, value: *mut c_void) -> Result<(), String> { + code(curl::curl_easy_setopt(easy.0, option, value)) +} + +unsafe fn set_str(easy: &Easy, option: curl::CURLoption, value: &CString) -> Result<(), String> { + code(curl::curl_easy_setopt(easy.0, option, value.as_ptr())) +} + +fn code(value: curl::CURLcode) -> Result<(), String> { + if value == curl::CURLE_OK { + Ok(()) + } else { + let detail = unsafe { CStr::from_ptr(curl::curl_easy_strerror(value)) }.to_string_lossy(); + Err(format!("curl: ({value}) {detail}")) + } +} + +fn impersonation_alias(value: &str) -> &str { + match value { + "chrome" => "chrome146", + "edge" => "edge101", + "safari" | "safari_beta" => "safari2601", + "safari_ios" | "safari_ios_beta" => "safari260_ios", + "chrome_android" => "chrome131_android", + "firefox" => "firefox147", + "tor" => "tor145", + other => other, + } +} + +fn request_url(url: &str, params: &[(String, String)]) -> Result<CString, String> { + let mut parsed = url::Url::parse(url).map_err(|error| error.to_string())?; + let mut merged: Vec<(String, String)> = parsed + .query_pairs() + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect(); + let mut old_counts = HashMap::new(); + let mut new_counts = HashMap::new(); + for (name, _) in &merged { + *old_counts.entry(name.clone()).or_insert(0usize) += 1; + } + for (name, _) in params { + *new_counts.entry(name.clone()).or_insert(0usize) += 1; + } + for (name, value) in params { + if old_counts.get(name.as_str()) == Some(&1) && new_counts.get(name.as_str()) == Some(&1) { + if let Some(existing) = merged.iter_mut().find(|(key, _)| key == name) { + existing.1 = value.clone(); + continue; + } + } + merged.push((name.clone(), value.clone())); + } + if !params.is_empty() { + parsed.query_pairs_mut().clear().extend_pairs(merged.iter()); + } + CString::new(parsed.as_str()).map_err(|_| "URL contains a NUL byte".to_string()) +} + +fn selected_proxy<'a>(url: &str, options: &'a HttpOptions) -> Option<&'a str> { + if let Some(proxy) = options.proxy.as_deref() { + return Some(proxy); + } + let parsed = url::Url::parse(url).ok()?; + let scheme = parsed.scheme(); + let host = parsed.host_str(); + let lookup = |wanted: &str| { + options + .proxies + .iter() + .find(|(key, _)| key == wanted) + .map(|(_, value)| value.as_str()) + }; + if let Some(host) = host { + if let Some(proxy) = + lookup(&format!("{scheme}://{host}")).or_else(|| lookup(&format!("all://{host}"))) + { + return Some(proxy); + } + } + lookup(scheme).or_else(|| lookup("all")) +} + +fn body_bytes(body: &Body) -> Result<(Vec<u8>, &'static str), String> { + match body { + Body::Json(value) => serde_json::to_vec(value) + .map(|bytes| (bytes, "application/json")) + .map_err(|error| error.to_string()), + Body::Form(Value::Object(values)) => { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + for (name, value) in values { + serializer.append_pair(name, &python_scalar(value)); + } + Ok(( + serializer.finish().into_bytes(), + "application/x-www-form-urlencoded", + )) + } + Body::Form(value) => Ok(( + python_scalar(value).into_bytes(), + "application/octet-stream", + )), + } +} + +fn response_headers(raw: &[u8]) -> Map<String, Value> { + let text = String::from_utf8_lossy(raw); + let block = text + .rsplit("\r\n\r\n") + .find(|block| block.starts_with("HTTP/")) + .unwrap_or(""); + let mut headers = Map::new(); + for line in block.lines().skip(1) { + if let Some((name, value)) = line.split_once(':') { + let name = name.trim().to_ascii_lowercase(); + let value = value.trim(); + if let Some(existing) = headers.get(&name).and_then(Value::as_str) { + let combined = format!("{existing}, {value}"); + headers.insert(name, combined.into()); + } else { + headers.insert(name, value.into()); + } + } + } + headers +} + +fn response_cookies(raw: &[u8]) -> Map<String, Value> { + let mut cookies = Map::new(); + let text = String::from_utf8_lossy(raw); + let block = text + .rsplit("\r\n\r\n") + .find(|block| block.starts_with("HTTP/")) + .unwrap_or(""); + for line in block.lines().skip(1) { + // Header names are case-insensitive; match any casing, not just the + // two common spellings. + let Some(value) = line + .split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("set-cookie")) + .map(|(_, value)| value) + else { + continue; + }; + if let Some((name, value)) = value.trim().split(';').next().unwrap_or("").split_once('=') { + cookies.insert(name.trim().to_string(), value.trim().into()); + } + } + cookies +} + +fn cookie_key(line: &str) -> Option<(&str, &str, &str)> { + let fields = line.split('\t').collect::<Vec<_>>(); + (fields.len() == 7).then(|| (fields[0], fields[2], fields[5])) +} + +fn apply_cookie_changes(cookies: &mut Vec<String>, changes: *mut curl::curl_slist) { + let mut current = changes; + while !current.is_null() { + let raw = unsafe { CStr::from_ptr((*current).data) }.to_string_lossy(); + if let Some((action, value)) = raw.split_once('\t') { + if let Some(key) = cookie_key(value) { + cookies.retain(|existing| cookie_key(existing) != Some(key)); + if action == "SET" { + cookies.push(value.to_string()); + } + } + } + current = unsafe { (*current).next }; + } + if !changes.is_null() { + unsafe { curl::curl_slist_free_all(changes) }; + } +} + +fn request_cookie_lines(url: &str, options: &HttpOptions) -> Result<Vec<CString>, String> { + let parsed = url::Url::parse(url).map_err(|error| error.to_string())?; + let mut host = parsed + .host_str() + .ok_or_else(|| "URL has no host".to_string())? + .to_ascii_lowercase(); + if !host.contains('.') && host.parse::<std::net::Ipv4Addr>().is_err() { + host.push_str(".local"); + } + options + .cookies + .iter() + .map(|(name, value)| { + CString::new(format!("{host}\tFALSE\t/\tFALSE\t0\t{name}\t{value}")) + .map_err(|_| "cookie contains a NUL byte".to_string()) + }) + .collect() +} + +fn one_attempt( + url: &str, + options: &HttpOptions, + session: &mut SessionState, +) -> Result<PageData, String> { + // curl-cffi reports setopt failures with the option number, caller value, + // and libcurl's option hex. Keep these validations ahead of handle setup + // so the Rust binding has the same deterministic error without relying on + // undefined behavior at the variadic FFI boundary. + if options.compat_timeout_ms < 0 { + return Err(format!( + "Failed to setopt 155 {}, curl: (43) setopt 0x9b got bad argument. See https://curl.se/libcurl/c/libcurl-errors.html first for more details.", + options.compat_timeout_ms + )); + } + if options.max_redirects < -1 { + return Err(format!( + "Failed to setopt 68 {}, curl: (43) setopt 0x44 got bad argument. See https://curl.se/libcurl/c/libcurl-errors.html first for more details.", + options.max_redirects + )); + } + let easy = &session.easy; + unsafe { curl::curl_easy_reset(easy.0) }; + let final_url = request_url(url, &options.params)?; + let mut keepalive = vec![final_url.clone()]; + let mut transfer = Transfer::default(); + let mut error_buffer = [0u8; 256]; + + unsafe { + set_str(easy, curl::CURLOPT_URL, &final_url)?; + set_long(easy, curl::CURLOPT_NOSIGNAL, 1)?; + set_long( + easy, + curl::CURLOPT_TIMEOUT_MS, + options.compat_timeout_ms as c_long, + )?; + set_long( + easy, + curl::CURLOPT_FOLLOWLOCATION, + options.compat_follow_redirects as c_long, + )?; + set_long( + easy, + curl::CURLOPT_MAXREDIRS, + options.max_redirects as c_long, + )?; + let accept_encoding = CString::new("gzip, deflate, br, zstd").unwrap(); + set_str(easy, curl::CURLOPT_ACCEPT_ENCODING, &accept_encoding)?; + keepalive.push(accept_encoding); + set_ptr( + easy, + curl::CURLOPT_ERRORBUFFER, + error_buffer.as_mut_ptr().cast(), + )?; + set_ptr( + easy, + curl::CURLOPT_WRITEDATA, + (&mut transfer as *mut Transfer).cast(), + )?; + code(curl::curl_easy_setopt( + easy.0, + curl::CURLOPT_WRITEFUNCTION, + write_body as unsafe extern "C" fn(*mut c_char, usize, usize, *mut c_void) -> usize, + ))?; + set_ptr( + easy, + curl::CURLOPT_HEADERDATA, + (&mut transfer as *mut Transfer).cast(), + )?; + code(curl::curl_easy_setopt( + easy.0, + curl::CURLOPT_HEADERFUNCTION, + write_header as unsafe extern "C" fn(*mut c_char, usize, usize, *mut c_void) -> usize, + ))?; + } + + let empty = CString::new("").unwrap(); + let clear = CString::new("ALL").unwrap(); + let session_cookies = session + .cookies + .iter() + .map(|value| CString::new(value.as_str()).expect("libcurl cookie lines contain no NUL")) + .collect::<Vec<_>>(); + let request_cookies = request_cookie_lines(url, options)?; + unsafe { + set_str(easy, curl::CURLOPT_COOKIEFILE, &empty)?; + set_str(easy, curl::CURLOPT_COOKIELIST, &clear)?; + for cookie in session_cookies.iter().chain(&request_cookies) { + set_str(easy, curl::CURLOPT_COOKIELIST, cookie)?; + } + } + keepalive.extend([empty, clear]); + + if let Some(target) = options.impersonate.as_deref() { + let target = CString::new(impersonation_alias(target)) + .map_err(|_| "impersonate contains a NUL byte".to_string())?; + let result = unsafe { curl::curl_easy_impersonate(easy.0, target.as_ptr(), 1) }; + if result != curl::CURLE_OK { + return Err(format!( + "Impersonating {} is not supported", + options.impersonate.as_deref().unwrap_or_default() + )); + } + keepalive.push(target); + } + unsafe { + if options.http3 { + set_long( + easy, + curl::CURLOPT_HTTP_VERSION, + curl::CURL_HTTP_VERSION_3ONLY, + )?; + } + set_long(easy, curl::CURLOPT_SSL_VERIFYPEER, options.verify as c_long)?; + set_long( + easy, + curl::CURLOPT_SSL_VERIFYHOST, + if options.verify { 2 } else { 0 }, + )?; + } + + for (option, credentials, auth_option) in [ + ( + curl::CURLOPT_USERPWD, + options.auth.as_ref(), + curl::CURLOPT_HTTPAUTH, + ), + ( + curl::CURLOPT_PROXYUSERPWD, + options.proxy_auth.as_ref(), + curl::CURLOPT_PROXYAUTH, + ), + ] { + if let Some((username, password)) = credentials { + let value = CString::new(format!("{username}:{password}")) + .map_err(|_| "credentials contain a NUL byte".to_string())?; + unsafe { + set_str(easy, option, &value)?; + set_long(easy, auth_option, curl::CURLAUTH_BASIC)?; + } + keepalive.push(value); + } + } + if let Some(proxy) = selected_proxy(url, options) { + let value = CString::new(proxy).map_err(|_| "proxy contains a NUL byte".to_string())?; + unsafe { set_str(easy, curl::CURLOPT_PROXY, &value)? }; + keepalive.push(value); + } + + let mut header_values = Vec::new(); + let mut headers = Slist::new(); + for (name, value) in &options.headers { + let line = if value.is_empty() { + format!("{name};") + } else { + format!("{name}: {value}") + }; + let value = CString::new(line).map_err(|_| format!("header {name} contains a NUL byte"))?; + headers.append(&value)?; + header_values.push(value); + } + let expect = CString::new("Expect:").unwrap(); + headers.append(&expect)?; + header_values.push(expect); + + let mut body = None; + if options.method_allows_body() + && (options.body.is_some() || matches!(options.method.as_str(), "post" | "put")) + { + let (bytes, content_type) = match &options.body { + Some(value) => { + let (bytes, content_type) = body_bytes(value)?; + (bytes, Some(content_type)) + } + None => (Vec::new(), None), + }; + if let Some(content_type) = content_type { + if !options + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("content-type")) + { + let value = CString::new(format!("Content-Type: {content_type}")).unwrap(); + headers.append(&value)?; + header_values.push(value); + } + } + unsafe { + set_ptr( + easy, + curl::CURLOPT_POSTFIELDS, + bytes.as_ptr().cast_mut().cast(), + )?; + set_long(easy, curl::CURLOPT_POSTFIELDSIZE, bytes.len() as c_long)?; + } + body = Some(bytes); + } + let method = CString::new(options.method.to_ascii_uppercase()).unwrap(); + if options.method != "get" { + unsafe { set_str(easy, curl::CURLOPT_CUSTOMREQUEST, &method)? }; + } + if !headers.0.is_null() { + unsafe { set_ptr(easy, curl::CURLOPT_HTTPHEADER, headers.0.cast())? }; + } + + let result = unsafe { curl::curl_easy_perform(easy.0) }; + let mut changes = ptr::null_mut(); + if unsafe { curl::curl_easy_getinfo(easy.0, curl::CURLINFO_COOKIECHANGES, &mut changes) } + == curl::CURLE_OK + { + apply_cookie_changes(&mut session.cookies, changes); + } + drop((body, method, header_values, keepalive)); + if result != curl::CURLE_OK { + let detail = error_buffer + .split(|byte| *byte == 0) + .next() + .and_then(|bytes| std::str::from_utf8(bytes).ok()) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| unsafe { + CStr::from_ptr(curl::curl_easy_strerror(result)) + .to_string_lossy() + .into_owned() + }); + return Err(format!( + "Failed to perform, curl: ({result}) {detail}. See https://curl.se/libcurl/c/libcurl-errors.html first for more details." + )); + } + + let mut status = 0 as c_long; + let mut effective: *mut c_char = ptr::null_mut(); + unsafe { + code(curl::curl_easy_getinfo( + easy.0, + curl::CURLINFO_RESPONSE_CODE, + &mut status, + ))?; + code(curl::curl_easy_getinfo( + easy.0, + curl::CURLINFO_EFFECTIVE_URL, + &mut effective, + ))?; + } + let effective = if effective.is_null() { + url.to_string() + } else { + unsafe { CStr::from_ptr(effective) } + .to_string_lossy() + .into_owned() + }; + let headers = response_headers(&transfer.headers); + let encoding = charset_of_raw(&headers).or_else(|| Some("utf-8".to_string())); + let html = super::decode_body(&transfer.body, encoding.as_deref()); + Ok(PageData { + status: u16::try_from(status).ok(), + url: effective, + headers, + cookies: response_cookies(&transfer.headers), + encoding, + html, + captured_xhr: vec![], + }) +} + +fn blocking_fetch_in_session( + url: &str, + options: &HttpOptions, + session: &mut SessionState, +) -> Result<PageData, String> { + let attempts = options.retries.max(0) as usize; + if attempts == 0 { + return Err("No active session available.".to_string()); + } + let mut last = String::new(); + for attempt in 0..attempts { + match one_attempt(url, options, session) { + Ok(page) => return Ok(page), + Err(error) => { + last = error; + if attempt + 1 < attempts && !options.retry_delay.is_zero() { + std::thread::sleep(options.retry_delay); + } else if attempt + 1 < attempts && options.retry_delay_negative { + return Err("sleep length must be non-negative".to_string()); + } + } + } + } + Err(last) +} + +fn blocking_fetch(url: &str, options: &HttpOptions) -> Result<PageData, String> { + let mut session = SessionState::new()?; + blocking_fetch_in_session(url, options, &mut session) +} + +pub async fn fetch_page(url: String, options: HttpOptions) -> Result<PageData, String> { + tokio::task::spawn_blocking(move || blocking_fetch(&url, &options)) + .await + .map_err(|error| format!("curl worker failed: {error}"))? +} + +pub(crate) async fn fetch_page_with_session( + url: String, + options: HttpOptions, + session: CompatSession, +) -> Result<PageData, String> { + tokio::task::spawn_blocking(move || { + let mut session = session + .0 + .lock() + .map_err(|_| "curl session lock is poisoned".to_string())?; + blocking_fetch_in_session(&url, &options, &mut session) + }) + .await + .map_err(|error| format!("curl worker failed: {error}"))? +} + +#[cfg(test)] +mod tests { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc; + + use serde_json::json; + + use super::*; + use crate::scrapling::fetch::HttpMode; + + fn one_request_server() -> (String, mpsc::Receiver<String>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut bytes = Vec::new(); + let mut chunk = [0u8; 4096]; + let mut expected = None; + loop { + let read = stream.read(&mut chunk).unwrap(); + if read == 0 { + break; + } + bytes.extend_from_slice(&chunk[..read]); + if expected.is_none() { + if let Some(end) = bytes.windows(4).position(|part| part == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&bytes[..end]); + let length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .and_then(|value| value.trim().parse::<usize>().ok()) + }) + .unwrap_or(0); + expected = Some(end + 4 + length); + } + } + if expected.is_some_and(|length| bytes.len() >= length) { + break; + } + } + sender.send(String::from_utf8(bytes).unwrap()).unwrap(); + stream + .write_all( + b"HTTP/1.1 201 Created\r\nContent-Type: text/plain; charset=iso-8859-1\r\nSet-Cookie: sid=abc; Path=/\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK", + ) + .unwrap(); + }); + (format!("http://{address}/endpoint"), receiver) + } + + fn cookie_server(count: usize) -> (String, mpsc::Receiver<String>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + for index in 0..count { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut chunk = [0u8; 4096]; + while !request.windows(4).any(|part| part == b"\r\n\r\n") { + let read = stream.read(&mut chunk).unwrap(); + request.extend_from_slice(&chunk[..read]); + } + sender.send(String::from_utf8(request).unwrap()).unwrap(); + let cookie = if index == 0 { + "Set-Cookie: stored=server; Path=/\r\n" + } else { + "" + }; + write!( + stream, + "HTTP/1.1 200 OK\r\n{cookie}Content-Length: 2\r\nConnection: close\r\n\r\nok" + ) + .unwrap(); + } + }); + (format!("http://{address}"), receiver) + } + + #[tokio::test] + async fn persistent_session_reuses_server_cookies_but_not_request_cookies() { + let (base, requests) = cookie_server(3); + let session = CompatSession::new().unwrap(); + let base_options = HttpOptions::from_payload_for_mode( + &json!({"impersonate":"", "stealthy_headers":false, "retries":1}), + HttpMode::Compat, + ) + .unwrap(); + fetch_page_with_session(format!("{base}/set"), base_options.clone(), session.clone()) + .await + .unwrap(); + let mut temporary = base_options.clone(); + temporary.cookies = vec![("once".to_string(), "request".to_string())]; + fetch_page_with_session(format!("{base}/temporary"), temporary, session.clone()) + .await + .unwrap(); + fetch_page_with_session(format!("{base}/again"), base_options, session) + .await + .unwrap(); + + let first = requests.recv().unwrap(); + let second = requests.recv().unwrap().to_ascii_lowercase(); + let third = requests.recv().unwrap().to_ascii_lowercase(); + assert!(!first.to_ascii_lowercase().contains("cookie:")); + assert!(second.contains("stored=server"), "{second}"); + assert!(second.contains("once=request"), "{second}"); + assert!(third.contains("stored=server"), "{third}"); + assert!(!third.contains("once=request"), "{third}"); + } + + #[test] + fn compat_sends_delete_body_and_preserves_ordered_inputs() { + let (url, request) = one_request_server(); + let payload = json!({ + "method": "delete", + "json": {"delete": true}, + "params": {"b": [2, 3], "a": "x"}, + "headers": {"x-second": "2", "x-first": "1"}, + "cookies": {"second": "2", "first": "1"}, + "auth": ["user", "pass"], + "impersonate": "chrome136", + "stealthy_headers": false, + "retries": 1, + "include_html": true + }); + let options = HttpOptions::from_payload_for_mode(&payload, HttpMode::Compat).unwrap(); + + let page = blocking_fetch(&url, &options).unwrap(); + let raw = request.recv().unwrap(); + assert!( + raw.starts_with("DELETE /endpoint?b=2&b=3&a=x HTTP/1.1\r\n"), + "{raw}" + ); + assert!(raw.ends_with("{\"delete\":true}"), "{raw}"); + assert!( + raw.contains("Authorization: Basic dXNlcjpwYXNz\r\n"), + "{raw}" + ); + assert!(raw.contains("Cookie: second=2; first=1\r\n"), "{raw}"); + assert!(raw.find("x-second: 2").unwrap() < raw.find("x-first: 1").unwrap()); + assert_eq!(page.status, Some(201)); + assert_eq!(page.cookies["sid"], json!("abc")); + assert_eq!(page.encoding.as_deref(), Some("iso-8859-1")); + assert_eq!(page.html, "OK"); + let envelope = crate::scrapling::page::serialize_page(&page, &payload, true).unwrap(); + assert_eq!(envelope["status"], json!(201)); + assert_eq!(envelope["url"], json!(format!("{url}?b=2&b=3&a=x"))); + assert_eq!(envelope["headers"]["set-cookie"], json!("sid=abc; Path=/")); + assert_eq!(envelope["cookies"]["sid"], json!("abc")); + assert_eq!(envelope["encoding"], json!("iso-8859-1")); + // HTML normalization belongs to the shared compatibility DOM. The + // HTTP engine contract at this boundary is the decoded response body. + assert_eq!(page.html, "OK"); + } + + #[test] + fn unsupported_impersonation_matches_oracle_error() { + let options = HttpOptions::from_payload_for_mode( + &json!({"impersonate": "bogus", "retries": 1}), + HttpMode::Compat, + ) + .unwrap(); + assert_eq!( + blocking_fetch("http://127.0.0.1:1", &options).unwrap_err(), + "Impersonating bogus is not supported" + ); + } + + #[test] + fn invalid_signed_setopt_values_match_oracle_errors() { + let timeout = HttpOptions::from_payload_for_mode( + &json!({ + "timeout": -1, + "retries": 1, + "impersonate": "chrome136", + "stealthy_headers": false + }), + HttpMode::Compat, + ) + .unwrap(); + assert_eq!( + blocking_fetch("http://127.0.0.1:1", &timeout).unwrap_err(), + "Failed to setopt 155 -1000, curl: (43) setopt 0x9b got bad argument. See https://curl.se/libcurl/c/libcurl-errors.html first for more details." + ); + + let redirects = HttpOptions::from_payload_for_mode( + &json!({ + "max_redirects": -2, + "retries": 1, + "impersonate": "chrome136", + "stealthy_headers": false + }), + HttpMode::Compat, + ) + .unwrap(); + assert_eq!( + blocking_fetch("http://127.0.0.1:1", &redirects).unwrap_err(), + "Failed to setopt 68 -2, curl: (43) setopt 0x44 got bad argument. See https://curl.se/libcurl/c/libcurl-errors.html first for more details." + ); + } + + #[test] + fn negative_retry_values_match_oracle_errors() { + let retries = HttpOptions::from_payload_for_mode( + &json!({ + "retries": -1, + "impersonate": "chrome136", + "stealthy_headers": false + }), + HttpMode::Compat, + ) + .unwrap(); + assert_eq!( + blocking_fetch("http://127.0.0.1:1", &retries).unwrap_err(), + "No active session available." + ); + + let retry_delay = HttpOptions::from_payload_for_mode( + &json!({ + "retries": 2, + "retry_delay": -1, + "impersonate": "chrome136", + "stealthy_headers": false + }), + HttpMode::Compat, + ) + .unwrap(); + assert_eq!( + blocking_fetch("http://127.0.0.1:1", &retry_delay).unwrap_err(), + "sleep length must be non-negative" + ); + } + + #[test] + fn form_values_use_python_urlencode_coercion() { + let body = Body::Form(json!({ + "bool": true, + "none": null, + "obj": {"a": 1}, + "arr": [1, 2] + })); + let (bytes, content_type) = body_bytes(&body).unwrap(); + assert_eq!(content_type, "application/x-www-form-urlencoded"); + assert_eq!( + String::from_utf8(bytes).unwrap(), + "bool=True&none=None&obj=%7B%27a%27%3A+1%7D&arr=%5B1%2C+2%5D" + ); + } + + #[test] + fn only_final_response_headers_and_cookies_are_exposed() { + let raw = b"HTTP/1.1 302 Found\r\nSet-Cookie: stale=1\r\nX-Hop: first\r\n\r\nHTTP/1.1 200 OK\r\nSet-Cookie: final=2\r\nSet-Cookie: other=3\r\nX-Hop: second\r\nX-Hop: third\r\n\r\n"; + let headers = response_headers(raw); + let cookies = response_cookies(raw); + assert_eq!(headers["x-hop"], json!("second, third")); + assert_eq!(headers["set-cookie"], json!("final=2, other=3")); + assert_eq!( + cookies, + json!({"final": "2", "other": "3"}) + .as_object() + .unwrap() + .clone() + ); + } + + #[test] + fn compat_decodes_content_encoding_like_curl_cffi() { + const GZIP_OK: &[u8] = &[ + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xf3, 0xf7, 0x06, 0x00, + 0x2d, 0xd9, 0x36, 0xd7, 0x02, 0x00, 0x00, 0x00, + ]; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0u8; 8192]; + let _ = stream.read(&mut request).unwrap(); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + GZIP_OK.len() + ) + .as_bytes(), + ) + .unwrap(); + stream.write_all(GZIP_OK).unwrap(); + }); + let options = HttpOptions::from_payload_for_mode( + &json!({ + "retries": 1, + "impersonate": "chrome136", + "stealthy_headers": false + }), + HttpMode::Compat, + ) + .unwrap(); + let page = blocking_fetch(&format!("http://{address}/gzip"), &options).unwrap(); + assert_eq!(page.html, "OK"); + assert_eq!(page.encoding.as_deref(), Some("utf-8")); + } +} diff --git a/browser/src/scrapling/inject_guidance.rs b/browser/src/scrapling/inject_guidance.rs new file mode 100644 index 000000000..0b5003ffa --- /dev/null +++ b/browser/src/scrapling/inject_guidance.rs @@ -0,0 +1,132 @@ +//! harness::hook::pre-generate guidance: teach agents the scrapling surface — +//! which fetch tier to reach for, what needs no browser at all, and which +//! capabilities this worker does NOT have. Bound with on_error: fail_open +//! (pre_generate defaults fail-CLOSED and a missing guidance line must never +//! abort a turn). + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +pub const GUIDANCE_HOOK_ID: &str = "browser::inject-guidance"; +pub const GUIDANCE_HOOK_DESC: &str = + "Internal: appends browser::* scraping and HTML parsing guidance to the agent system prompt."; + +pub const GUIDANCE: &str = "\ +## Scraping and HTML parsing (browser::*) +Use `browser::fetch` for plain HTTP, `browser::dynamic-fetch` for JavaScript, \ +and `browser::stealthy-fetch` for automation masking. Use \ +`browser::screenshot-url` to capture a URL; `browser::screenshot` captures an \ +existing interactive session. `browser::session-open`, \ +`browser::session-fetch`, `browser::session-close`, and \ +`browser::session-list` preserve cookies and browser state. `browser::crawl` \ +walks same-domain links and streams results. For HTML already in hand, use \ +`browser::extract`, `browser::css`, `browser::xpath`, `browser::regex`, \ +`browser::find`, `browser::find-by-text`, `browser::find-by-regex`, \ +`browser::find-similar`, `browser::describe`, or `browser::to-markdown`. \ +Fetching functions require approval; parse functions do not. Adaptive \ +queries persist identities in SQLite. `solve_cloudflare` is supported by \ +stealthy fetches; use `browser::handoff` for human-only steps in an \ +interactive session."; + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct GenerateContext { + #[serde(default)] + pub system_prompt: String, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct PreGenerateEvent { + #[serde(default)] + pub generate: GenerateContext, +} + +#[derive(Debug, Default, Serialize, JsonSchema)] +pub struct PreGenerateMutations { + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt: Option<String>, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct PreGenerateResponse { + pub mutations: PreGenerateMutations, +} + +/// Empty base → {} (preserve harness prompt); else full replacement base+guidance. +pub fn mutations_for(base: &str) -> PreGenerateResponse { + if base.is_empty() { + return PreGenerateResponse { + mutations: PreGenerateMutations::default(), + }; + } + PreGenerateResponse { + mutations: PreGenerateMutations { + system_prompt: Some(format!("{base}\n\n{GUIDANCE}")), + }, + } +} + +pub async fn handle(event: PreGenerateEvent) -> Result<PreGenerateResponse, iii_sdk::Error> { + Ok(mutations_for(&event.generate.system_prompt)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_base_preserves_harness_prompt() { + let v = serde_json::to_value(mutations_for("")).unwrap(); + assert_eq!(v, serde_json::json!({"mutations": {}})); + } + + #[test] + fn nonempty_base_appends_guidance() { + let v = serde_json::to_value(mutations_for("BASE")).unwrap(); + let s = v["mutations"]["system_prompt"].as_str().unwrap(); + assert!(s.starts_with("BASE\n\n## Scraping and HTML parsing")); + } + + /// The guidance is what routes agents to these functions, so it must not + /// promise capabilities the worker does not have, and every Scrapling id + /// it names must actually be registered. + #[test] + fn guidance_names_only_real_functions_and_no_absent_capability() { + let registered: Vec<&str> = crate::scrapling::STATIC_IDS + .iter() + .map(|id| id.trim_start_matches("browser::")) + .collect(); + for word in [ + "fetch", + "dynamic-fetch", + "stealthy-fetch", + "screenshot-url", + "session-open", + "session-fetch", + "session-close", + "session-list", + "crawl", + "extract", + "css", + "xpath", + "regex", + "find", + "find-by-text", + "find-by-regex", + "find-similar", + "describe", + "to-markdown", + ] { + assert!( + GUIDANCE.contains(word), + "guidance never mentions `{word}`, so agents will not find it" + ); + assert!(registered.contains(&word), "`{word}` is not registered"); + } + assert!( + GUIDANCE.contains("Adaptive queries persist identities in SQLite"), + "adaptive persistence must be disclosed" + ); + assert!(GUIDANCE.contains("`solve_cloudflare` is supported")); + assert!(GUIDANCE.contains("browser::handoff")); + } +} diff --git a/browser/src/scrapling/markdown.rs b/browser/src/scrapling/markdown.rs new file mode 100644 index 000000000..1d53e58b8 --- /dev/null +++ b/browser/src/scrapling/markdown.rs @@ -0,0 +1,1235 @@ +//! scrapling::to-markdown rendering: html/markdown/text extraction, with an +//! optional main-content sanitizer and CSS scope +//! (`Convertor._extract_content`, scrapling/core/shell.py, normative). + +use std::collections::HashSet; + +use xmloxide::tree::NodeKind; +use xmloxide::NodeId; + +use crate::scrapling::dom::{self, Doc, ElementRef}; +use crate::scrapling::query; + +/// `_strip_noise_tags` + the tag half of `_sanitize_for_ai`'s `_HIDDEN_XPATH` +/// union: `<template>` is dropped the same way as the noise tags — folding +/// it into one name-set costs nothing since detaching is order-independent +/// (matching is by predicate only, never by "is a sibling already gone"). +fn is_noise_tag(name: &str) -> bool { + matches!(name, "script" | "style" | "noscript" | "svg" | "template") +} + +/// The `contains(@style, ...)` half of `_HIDDEN_XPATH` (shell.py) — plain +/// substring tests, not CSS parsing. +const HIDDEN_STYLE_SUBSTRINGS: &[&str] = &[ + "display:none", + "display: none", + "visibility:hidden", + "visibility: hidden", + "opacity:0", + "opacity: 0", + "font-size:0", + "font-size: 0", + "height:0", + "height: 0", + "width:0", + "width: 0", +]; + +/// `_ZWC_PATTERN` (shell.py): zero-width/invisible-formatting characters +/// stripped from text nodes by the prompt-injection sanitizer. +const ZERO_WIDTH_CHARS: [char; 6] = [ + '\u{200b}', '\u{200c}', '\u{200d}', '\u{feff}', '\u{2060}', '\u{180e}', +]; + +fn is_hidden(doc: &Doc, id: NodeId) -> bool { + is_noise_tag(doc.tree.node_name(id).unwrap_or("")) + || doc.tree.attribute(id, "aria-hidden") == Some("true") + || doc + .tree + .attribute(id, "style") + .is_some_and(|s| HIDDEN_STYLE_SUBSTRINGS.iter().any(|pat| s.contains(pat))) +} + +/// `_strip_noise_tags` + `_sanitize_for_ai`, fused into one in-place pass over +/// a tree the caller already cloned (never called on the caller's own doc): +/// detach every noise/hidden/aria-hidden/template element (`drop_tree` — +/// lxml keeps a dropped element's TAIL text by splicing it onto the +/// predecessor, but html5ever/scraper never attaches trailing text to an +/// element in the first place — it's already an ordinary sibling text node — +/// so a plain `detach()` reproduces that splice for free), then strip +/// zero-width chars from every surviving text node. +/// +/// `scope_id` (the element `render` is about to treat as the page — `body`, +/// or the whole document if there's no `body`) is exempt from the hidden +/// check: `_HIDDEN_XPATH` is rooted at `.//` (XPath descendant axis, self +/// excluded), so `<body aria-hidden="true">` or `<body style="display: +/// none">` never drops itself in Python, only matching *descendants* do. +/// Oracle-probed: both yield their content, not `""`. The name-based checks +/// (script/style/noscript/svg/template) need no such exemption in practice — +/// `scope_id` is always a `<body>` or the document's `<html>` root, never one +/// of those tag names — but exempting it from the whole predicate uniformly +/// is simpler than splitting the check in two, and is a no-op for that half. +fn sanitize_main_content(doc: &mut Doc, scope_id: NodeId) { + let drop_ids: Vec<_> = doc + .tree + .descendants(doc.tree.root()) + .filter(|id| *id != scope_id && doc.tree.is_element(*id) && is_hidden(doc, *id)) + .collect(); + for id in drop_ids { + doc.tree.remove_node(id); + } + + let text_ids: Vec<_> = doc + .tree + .descendants(doc.tree.root()) + .filter(|id| matches!(doc.tree.node(*id).kind, NodeKind::Text { .. })) + .collect(); + for id in text_ids { + let cleaned: String = doc + .tree + .node_text(id) + .unwrap_or("") + .chars() + .filter(|c| !ZERO_WIDTH_CHARS.contains(c)) + .collect(); + doc.tree.set_text_content(id, &cleaned); + } +} + +/// First `<body>` in document order, else the whole document's root element +/// (`page.css("body").first or page`, shell.py). +fn body_or_root(doc: &Doc) -> ElementRef<'_> { + doc.first_by_tag("body").unwrap_or_else(|| doc.root()) +} + +/// Collapse runs of `\n`, then `\r`, then `\t`, then space — in that order, +/// each to a single occurrence (the `Convertor._extract_content` text-mode +/// loop, shell.py: sequential `re.sub(f"[{s}]+", s, ...)` per character). +/// markdownify's `all_whitespace_re` = `[\t \r\n]+` → single space. Crucially +/// ASCII-only: NBSP (and other Unicode spaces) are NOT collapsed, unlike +/// Rust's `split_whitespace`, which treats U+00A0 as whitespace. +fn collapse_ascii_ws(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut prev_ws = false; + for ch in s.chars() { + if matches!(ch, '\t' | ' ' | '\r' | '\n') { + if !prev_ws { + out.push(' '); + prev_ws = true; + } + } else { + out.push(ch); + prev_ws = false; + } + } + out +} + +fn collapse_whitespace(mut s: String) -> String { + for c in ['\n', '\r', '\t', ' '] { + let mut out = String::with_capacity(s.len()); + let mut prev_was_c = false; + for ch in s.chars() { + if ch == c { + if prev_was_c { + continue; + } + prev_was_c = true; + } else { + prev_was_c = false; + } + out.push(ch); + } + s = out; + } + s +} + +// The public wrapper exposes markdownify with no option overrides. This is a +// repository-owned port of markdownify 1.2.3's complete default conversion +// behavior. Its first step deliberately reparses the lxml-serialized subtree +// with the same tree shape as BeautifulSoup 4.15's `html.parser` builder. +// That second parse is observable for raw-text elements such as <plaintext>. + +#[derive(Clone, Debug)] +enum MarkdownNodeKind { + Document, + Element { + name: String, + attrs: Vec<(String, String)>, + }, + Text(String), + Comment, + Doctype, +} + +#[derive(Clone, Debug)] +struct MarkdownNode { + kind: MarkdownNodeKind, + parent: Option<usize>, + children: Vec<usize>, +} + +#[derive(Debug)] +struct MarkdownTree { + nodes: Vec<MarkdownNode>, +} + +impl MarkdownTree { + fn new() -> Self { + Self { + nodes: vec![MarkdownNode { + kind: MarkdownNodeKind::Document, + parent: None, + children: Vec::new(), + }], + } + } + + fn append(&mut self, parent: usize, kind: MarkdownNodeKind) -> usize { + let id = self.nodes.len(); + self.nodes.push(MarkdownNode { + kind, + parent: Some(parent), + children: Vec::new(), + }); + self.nodes[parent].children.push(id); + id + } + + fn name(&self, id: usize) -> Option<&str> { + match &self.nodes[id].kind { + MarkdownNodeKind::Document => Some("[document]"), + MarkdownNodeKind::Element { name, .. } => Some(name), + _ => None, + } + } + + fn attr(&self, id: usize, name: &str) -> Option<&str> { + match &self.nodes[id].kind { + MarkdownNodeKind::Element { attrs, .. } => attrs + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.as_str()), + _ => None, + } + } + + fn element_children_recursive(&self, id: usize, names: &[&str], out: &mut Vec<usize>) { + for child in &self.nodes[id].children { + if self.name(*child).is_some_and(|name| names.contains(&name)) { + out.push(*child); + } + self.element_children_recursive(*child, names, out); + } + } + + fn sibling_index(&self, id: usize) -> Option<(usize, usize)> { + let parent = self.nodes[id].parent?; + let index = self.nodes[parent] + .children + .iter() + .position(|candidate| *candidate == id)?; + Some((parent, index)) + } + + fn previous_sibling(&self, id: usize) -> Option<usize> { + let (parent, index) = self.sibling_index(id)?; + index.checked_sub(1).map(|i| self.nodes[parent].children[i]) + } + + fn next_sibling(&self, id: usize) -> Option<usize> { + let (parent, index) = self.sibling_index(id)?; + self.nodes[parent].children.get(index + 1).copied() + } + + fn previous_element_sibling(&self, id: usize) -> Option<usize> { + let (parent, index) = self.sibling_index(id)?; + self.nodes[parent].children[..index] + .iter() + .rev() + .copied() + .find(|candidate| self.name(*candidate).is_some()) + } + + fn has_ancestor(&self, id: usize, name: &str) -> bool { + let mut cursor = self.nodes[id].parent; + while let Some(parent) = cursor { + if self.name(parent) == Some(name) { + return true; + } + cursor = self.nodes[parent].parent; + } + false + } +} + +const VOID_TAGS: &[&str] = &[ + "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", + "track", "wbr", +]; + +fn decode_html_parser_entities(value: &str) -> String { + html_escape::decode_html_entities(value).into_owned() +} + +fn find_tag_end(input: &str, start: usize) -> usize { + let bytes = input.as_bytes(); + let mut quote = None; + let mut i = start; + while i < bytes.len() { + match bytes[i] { + b'\'' | b'"' if quote.is_none() => quote = Some(bytes[i]), + current if quote == Some(current) => quote = None, + b'>' if quote.is_none() => return i, + _ => {} + } + i += 1; + } + bytes.len() +} + +fn parse_start_tag(raw: &str) -> (String, Vec<(String, String)>, bool) { + let bytes = raw.as_bytes(); + let mut i = 0; + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + let name_start = i; + while i < bytes.len() && !bytes[i].is_ascii_whitespace() && !matches!(bytes[i], b'/' | b'>') { + i += 1; + } + let name = raw[name_start..i].to_ascii_lowercase(); + let mut attrs: Vec<(String, String)> = Vec::new(); + let mut self_closing = false; + + while i < bytes.len() { + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + if i >= bytes.len() { + break; + } + if bytes[i] == b'/' { + self_closing = true; + i += 1; + continue; + } + let attr_start = i; + while i < bytes.len() + && !bytes[i].is_ascii_whitespace() + && !matches!(bytes[i], b'=' | b'/' | b'>') + { + i += 1; + } + if attr_start == i { + i += 1; + continue; + } + let attr_name = raw[attr_start..i].to_ascii_lowercase(); + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + let mut value = String::new(); + if i < bytes.len() && bytes[i] == b'=' { + i += 1; + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + if i < bytes.len() && matches!(bytes[i], b'\'' | b'"') { + let quote = bytes[i]; + i += 1; + let value_start = i; + while i < bytes.len() && bytes[i] != quote { + i += 1; + } + value = decode_html_parser_entities(&raw[value_start..i]); + if i < bytes.len() { + i += 1; + } + } else { + let value_start = i; + while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'/' { + i += 1; + } + value = decode_html_parser_entities(&raw[value_start..i]); + } + } + // html.parser/BeautifulSoup keeps the last duplicate attribute. + if let Some(existing) = attrs.iter_mut().find(|(key, _)| key == &attr_name) { + existing.1 = value; + } else { + attrs.push((attr_name, value)); + } + } + (name, attrs, self_closing) +} + +/// Parse the already libxml-serialized selection the way the default +/// BeautifulSoup HTMLParserTreeBuilder observes it. Libxml has already done +/// malformed-markup recovery, so this stage primarily needs HTMLParser's +/// tokenization, entity handling, raw-text behavior, and sibling tree. +fn parse_markdown_tree(input: &str) -> MarkdownTree { + let mut tree = MarkdownTree::new(); + let mut stack = vec![0usize]; + let mut i = 0usize; + let bytes = input.as_bytes(); + + while i < bytes.len() { + if bytes[i] != b'<' { + let end = input[i..] + .find('<') + .map_or(bytes.len(), |offset| i + offset); + if end > i { + let text = decode_html_parser_entities(&input[i..end]); + tree.append(*stack.last().unwrap(), MarkdownNodeKind::Text(text)); + } + i = end; + continue; + } + if input[i..].starts_with("<!--") { + let end = input[i + 4..] + .find("-->") + .map_or(bytes.len(), |offset| i + 4 + offset + 3); + tree.append(*stack.last().unwrap(), MarkdownNodeKind::Comment); + i = end; + continue; + } + if input[i..].get(..2).is_some_and(|s| s == "</") { + let end = find_tag_end(input, i + 2); + let name = input[i + 2..end] + .split_ascii_whitespace() + .next() + .unwrap_or("") + .trim_end_matches('/') + .to_ascii_lowercase(); + if let Some(position) = stack + .iter() + .rposition(|node| tree.name(*node) == Some(name.as_str())) + { + stack.truncate(position); + } + i = end.saturating_add(1); + continue; + } + if input[i..].get(..2).is_some_and(|s| s == "<!") { + let end = find_tag_end(input, i + 2); + tree.append(*stack.last().unwrap(), MarkdownNodeKind::Doctype); + i = end.saturating_add(1); + continue; + } + if input[i..].get(..2).is_some_and(|s| s == "<?") { + let end = find_tag_end(input, i + 2); + tree.append(*stack.last().unwrap(), MarkdownNodeKind::Comment); + i = end.saturating_add(1); + continue; + } + + let end = find_tag_end(input, i + 1); + if end == bytes.len() { + tree.append( + *stack.last().unwrap(), + MarkdownNodeKind::Text(decode_html_parser_entities(&input[i..])), + ); + break; + } + let (name, attrs, self_closing) = parse_start_tag(&input[i + 1..end]); + if name.is_empty() { + tree.append( + *stack.last().unwrap(), + MarkdownNodeKind::Text("<".to_string()), + ); + i += 1; + continue; + } + let node = tree.append( + *stack.last().unwrap(), + MarkdownNodeKind::Element { + name: name.clone(), + attrs, + }, + ); + i = end + 1; + + if name == "plaintext" { + // Python's HTMLParser treats every remaining byte, including + // apparent closing tags and entities, as literal plaintext. + if i < bytes.len() { + tree.append(node, MarkdownNodeKind::Text(input[i..].to_string())); + } + break; + } + if matches!(name.as_str(), "script" | "style") { + let closing = format!("</{name}"); + let lower_tail = input[i..].to_ascii_lowercase(); + if let Some(offset) = lower_tail.find(&closing) { + if offset > 0 { + tree.append( + node, + MarkdownNodeKind::Text(input[i..i + offset].to_string()), + ); + } + let close_start = i + offset; + i = find_tag_end(input, close_start + 2).saturating_add(1); + continue; + } + } + if !self_closing && !VOID_TAGS.contains(&name.as_str()) { + stack.push(node); + } + } + tree +} + +fn heading_number(name: &str) -> Option<usize> { + let digits: String = name + .strip_prefix('h')? + .chars() + .take_while(char::is_ascii_digit) + .collect(); + (!digits.is_empty()).then(|| digits.parse().ok()).flatten() +} + +fn is_block_name(name: Option<&str>) -> bool { + name.is_some_and(|name| { + heading_number(name).is_some() + || matches!( + name, + "p" | "blockquote" + | "article" + | "div" + | "section" + | "ol" + | "ul" + | "li" + | "dl" + | "dt" + | "dd" + | "table" + | "thead" + | "tbody" + | "tfoot" + | "tr" + | "td" + | "th" + ) + }) +} + +fn removes_whitespace_outside(tree: &MarkdownTree, node: Option<usize>) -> bool { + node.is_some_and(|id| is_block_name(tree.name(id)) || tree.name(id) == Some("pre")) +} + +fn block_content(tree: &MarkdownTree, node: usize) -> bool { + match &tree.nodes[node].kind { + MarkdownNodeKind::Element { .. } => true, + MarkdownNodeKind::Text(text) => !text.trim().is_empty(), + _ => false, + } +} + +fn next_block_content(tree: &MarkdownTree, id: usize) -> Option<usize> { + let (parent, index) = tree.sibling_index(id)?; + tree.nodes[parent].children[index + 1..] + .iter() + .copied() + .find(|candidate| block_content(tree, *candidate)) +} + +fn normalize_markdown_text(text: &str) -> String { + let chars: Vec<char> = text.chars().collect(); + let mut out = String::with_capacity(text.len()); + let mut i = 0; + while i < chars.len() { + if matches!(chars[i], ' ' | '\t' | '\r' | '\n') { + let mut has_newline = false; + while i < chars.len() && matches!(chars[i], ' ' | '\t' | '\r' | '\n') { + has_newline |= matches!(chars[i], '\r' | '\n'); + i += 1; + } + out.push(if has_newline { '\n' } else { ' ' }); + } else { + out.push(chars[i]); + i += 1; + } + } + out +} + +fn escape_markdown_text(text: &str) -> String { + text.replace('*', r"\*").replace('_', r"\_") +} + +fn trim_ascii_markdown(value: &str) -> &str { + value.trim_matches([' ', '\t', '\r', '\n']) +} + +fn chomp(value: &str) -> (&'static str, &'static str, &str) { + let prefix = if value.starts_with(' ') { " " } else { "" }; + let suffix = if value.ends_with(' ') { " " } else { "" }; + (prefix, suffix, value.trim()) +} + +fn split_boundary_newlines(value: &str) -> (String, String, String) { + let leading = value.bytes().take_while(|byte| *byte == b'\n').count(); + let remainder = &value[leading..]; + if remainder.is_empty() { + return ("\n".repeat(leading), String::new(), String::new()); + } + let trailing = remainder + .bytes() + .rev() + .take_while(|byte| *byte == b'\n') + .count(); + let content_end = remainder.len() - trailing; + ( + "\n".repeat(leading), + remainder[..content_end].to_string(), + "\n".repeat(trailing), + ) +} + +fn indent_lines(value: &str, prefix: &str, empty_prefix: &str) -> String { + value + .split('\n') + .map(|line| { + if line.is_empty() { + empty_prefix.to_string() + } else { + format!("{prefix}{line}") + } + }) + .collect::<Vec<_>>() + .join("\n") +} + +struct MarkdownConverter<'a> { + tree: &'a MarkdownTree, +} + +impl<'a> MarkdownConverter<'a> { + fn process(&self) -> String { + self.process_tag(0, &HashSet::new()) + } + + fn process_element(&self, id: usize, parent_tags: &HashSet<String>) -> String { + match &self.tree.nodes[id].kind { + MarkdownNodeKind::Text(_) => self.process_text(id, parent_tags), + MarkdownNodeKind::Element { .. } | MarkdownNodeKind::Document => { + self.process_tag(id, parent_tags) + } + MarkdownNodeKind::Comment | MarkdownNodeKind::Doctype => String::new(), + } + } + + fn can_ignore_child(&self, child: usize, remove_inside: bool) -> bool { + match &self.tree.nodes[child].kind { + MarkdownNodeKind::Element { .. } => false, + MarkdownNodeKind::Comment | MarkdownNodeKind::Doctype => true, + MarkdownNodeKind::Text(text) => { + if !text.trim().is_empty() { + return false; + } + let previous = self.tree.previous_sibling(child); + let next = self.tree.next_sibling(child); + (remove_inside && (previous.is_none() || next.is_none())) + || removes_whitespace_outside(self.tree, previous) + || removes_whitespace_outside(self.tree, next) + } + MarkdownNodeKind::Document => true, + } + } + + fn process_tag(&self, id: usize, parent_tags: &HashSet<String>) -> String { + let name = self.tree.name(id).unwrap_or(""); + let remove_inside = is_block_name(Some(name)); + let mut child_context = parent_tags.clone(); + child_context.insert(name.to_string()); + if heading_number(name).is_some() || matches!(name, "td" | "th") { + child_context.insert("_inline".to_string()); + } + if matches!(name, "pre" | "code" | "kbd" | "samp") { + child_context.insert("_noformat".to_string()); + } + + let mut child_strings: Vec<String> = self.tree.nodes[id] + .children + .iter() + .copied() + .filter(|child| !self.can_ignore_child(*child, remove_inside)) + .map(|child| self.process_element(child, &child_context)) + .filter(|value| !value.is_empty()) + .collect(); + + if name != "pre" && !self.tree.has_ancestor(id, "pre") { + let mut collapsed = vec![String::new()]; + for child in child_strings { + let (mut leading, content, trailing) = split_boundary_newlines(&child); + if collapsed.last().is_some_and(|last| !last.is_empty()) && !leading.is_empty() { + let previous = collapsed.pop().unwrap(); + leading = "\n".repeat(2.min(previous.len().max(leading.len()))); + } + collapsed.extend([leading, content, trailing]); + } + child_strings = collapsed; + } + let text = child_strings.concat(); + self.convert_tag(id, name, text, parent_tags) + } + + fn process_text(&self, id: usize, parent_tags: &HashSet<String>) -> String { + let MarkdownNodeKind::Text(raw) = &self.tree.nodes[id].kind else { + unreachable!() + }; + let mut text = if parent_tags.contains("pre") { + raw.clone() + } else { + normalize_markdown_text(raw) + }; + if !parent_tags.contains("_noformat") { + text = escape_markdown_text(&text); + } + + let parent = self.tree.nodes[id].parent; + let previous = self.tree.previous_sibling(id); + let next = self.tree.next_sibling(id); + if removes_whitespace_outside(self.tree, previous) + || (parent.is_some_and(|node| is_block_name(self.tree.name(node))) + && previous.is_none()) + { + text = text.trim_start_matches([' ', '\t', '\r', '\n']).to_string(); + } + if removes_whitespace_outside(self.tree, next) + || (parent.is_some_and(|node| is_block_name(self.tree.name(node))) && next.is_none()) + { + text = text.trim_end().to_string(); + } + text + } + + fn inline(&self, text: String, markup: &str, parent_tags: &HashSet<String>) -> String { + if parent_tags.contains("_noformat") { + return text; + } + let (prefix, suffix, inner) = chomp(&text); + if inner.is_empty() { + String::new() + } else { + format!("{prefix}{markup}{inner}{markup}{suffix}") + } + } + + fn convert_tag( + &self, + id: usize, + name: &str, + text: String, + parent_tags: &HashSet<String>, + ) -> String { + if name == "[document]" { + return text.trim_matches('\n').to_string(); + } + if let Some(number) = heading_number(name) { + return self.convert_heading(number, &text, parent_tags); + } + match name { + "a" => self.convert_a(id, text, parent_tags), + "b" | "strong" => self.inline(text, "**", parent_tags), + "em" | "i" => self.inline(text, "*", parent_tags), + "del" | "s" => self.inline(text, "~~", parent_tags), + "sub" | "sup" => self.inline(text, "", parent_tags), + "code" | "kbd" | "samp" => self.convert_code(text, parent_tags), + "blockquote" => self.convert_blockquote(text, parent_tags), + "br" => { + if parent_tags.contains("_inline") { + if text.is_empty() { + " ".into() + } else { + format!("{text} ") + } + } else { + format!(" \n{text}") + } + } + "div" | "article" | "section" => self.convert_div(text, parent_tags), + "dd" => self.convert_dd(text, parent_tags), + "dt" => self.convert_dt(text, parent_tags), + "dl" => self.convert_div(text, parent_tags), + "hr" => "\n\n---\n\n".to_string(), + "img" => self.convert_img(id, parent_tags), + "video" => self.convert_video(id, text, parent_tags), + "ul" | "ol" => self.convert_list(id, text, parent_tags), + "li" => self.convert_li(id, text), + "p" => self.convert_p(text, parent_tags), + "pre" => self.convert_pre(text), + "q" => format!("\"{text}\""), + "script" | "style" => String::new(), + "table" => format!("\n\n{}\n\n", text.trim()), + "caption" => format!("{}\n\n", text.trim()), + "figcaption" => format!("\n\n{}\n\n", text.trim()), + "td" | "th" => self.convert_cell(id, text), + "tr" => self.convert_row(id, text), + _ => text, + } + } + + fn convert_a(&self, id: usize, text: String, parent_tags: &HashSet<String>) -> String { + if parent_tags.contains("_noformat") { + return text; + } + let (prefix, suffix, inner) = chomp(&text); + if inner.is_empty() { + return String::new(); + } + // markdownify treats an empty href/title as absent (`if href`, + // `not title` are falsy on ""), so an empty href is no link and an + // empty title never blocks the autolink form. + let href = self.tree.attr(id, "href").filter(|value| !value.is_empty()); + let title = self + .tree + .attr(id, "title") + .filter(|value| !value.is_empty()); + if href.is_some_and(|href| inner.replace(r"\_", "_") == href) && title.is_none() { + return format!("<{href}>", href = href.unwrap()); + } + let Some(href) = href else { + return inner.to_string(); + }; + let title = title + .map(|value| format!(" \"{}\"", value.replace('"', r#"\""#))) + .unwrap_or_default(); + format!("{prefix}[{inner}]({href}{title}){suffix}") + } + + fn convert_blockquote(&self, text: String, parent_tags: &HashSet<String>) -> String { + let text = trim_ascii_markdown(&text); + if parent_tags.contains("_inline") { + return format!(" {text} "); + } + if text.is_empty() { + return "\n".to_string(); + } + format!("\n{}\n\n", indent_lines(text, "> ", ">")) + } + + fn convert_code(&self, text: String, parent_tags: &HashSet<String>) -> String { + if parent_tags.contains("_noformat") { + return text; + } + let (prefix, suffix, inner) = chomp(&text); + if inner.is_empty() { + return String::new(); + } + let bytes = inner.as_bytes(); + let mut maximum = 0; + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'`' { + let start = i; + while i < bytes.len() && bytes[i] == b'`' { + i += 1; + } + maximum = maximum.max(i - start); + } else { + i += 1; + } + } + let delimiter = "`".repeat(maximum + 1); + let inner = if maximum > 0 { + format!(" {inner} ") + } else { + inner.to_string() + }; + format!("{prefix}{delimiter}{inner}{delimiter}{suffix}") + } + + fn convert_div(&self, text: String, parent_tags: &HashSet<String>) -> String { + if parent_tags.contains("_inline") { + return format!(" {} ", text.trim()); + } + let text = text.trim(); + if text.is_empty() { + String::new() + } else { + format!("\n\n{text}\n\n") + } + } + + fn convert_dd(&self, text: String, parent_tags: &HashSet<String>) -> String { + let text = text.trim(); + if parent_tags.contains("_inline") { + return format!(" {text} "); + } + if text.is_empty() { + return "\n".to_string(); + } + let indented = indent_lines(text, " ", ""); + format!(":{}\n", &indented[1..]) + } + + fn convert_dt(&self, text: String, parent_tags: &HashSet<String>) -> String { + let text = collapse_ascii_ws(text.trim()); + if parent_tags.contains("_inline") { + return format!(" {text} "); + } + if text.is_empty() { + "\n".to_string() + } else { + format!("\n\n{text}\n") + } + } + + fn convert_heading(&self, number: usize, text: &str, parent_tags: &HashSet<String>) -> String { + if parent_tags.contains("_inline") { + return text.to_string(); + } + let number = number.clamp(1, 6); + let text = text.trim(); + if number <= 2 { + if text.is_empty() { + return String::new(); + } + let underline = if number == 1 { '=' } else { '-' }; + return format!( + "\n\n{text}\n{}\n\n", + underline.to_string().repeat(text.chars().count()) + ); + } + let text = collapse_ascii_ws(text); + format!("\n\n{} {text}\n\n", "#".repeat(number)) + } + + fn convert_img(&self, id: usize, parent_tags: &HashSet<String>) -> String { + let alt = self.tree.attr(id, "alt").unwrap_or(""); + if parent_tags.contains("_inline") { + return alt.to_string(); + } + let src = self.tree.attr(id, "src").unwrap_or(""); + let title = self + .tree + .attr(id, "title") + .filter(|value| !value.is_empty()) + .map(|value| format!(" \"{}\"", value.replace('"', r#"\""#))) + .unwrap_or_default(); + format!("![{alt}]({src}{title})") + } + + fn convert_video(&self, id: usize, text: String, parent_tags: &HashSet<String>) -> String { + if parent_tags.contains("_inline") { + return text; + } + let mut src = self.tree.attr(id, "src").unwrap_or(""); + if src.is_empty() { + let mut sources = Vec::new(); + self.tree + .element_children_recursive(id, &["source"], &mut sources); + src = sources + .iter() + .find_map(|source| self.tree.attr(*source, "src")) + .unwrap_or(""); + } + let poster = self.tree.attr(id, "poster").unwrap_or(""); + match (src.is_empty(), poster.is_empty()) { + (false, false) => format!("[![{text}]({poster})]({src})"), + (false, true) => format!("[{text}]({src})"), + (true, false) => format!("![{text}]({poster})"), + (true, true) => text, + } + } + + fn convert_list(&self, id: usize, text: String, parent_tags: &HashSet<String>) -> String { + // markdownify: before_paragraph is true when the next block-content + // sibling is anything but another list — INCLUDING a bare text node + // (name None), which the old `.and_then(name)` dropped, so a list + // trailed by text lost its separating blank line. + let before_paragraph = next_block_content(self.tree, id) + .is_some_and(|node| !matches!(self.tree.name(node), Some("ul" | "ol"))); + if parent_tags.contains("li") { + return format!("\n{}", text.trim_end()); + } + format!("\n\n{text}{}", if before_paragraph { "\n" } else { "" }) + } + + fn convert_li(&self, id: usize, text: String) -> String { + let text = text.trim(); + if text.is_empty() { + return "\n".to_string(); + } + let parent = self.tree.nodes[id].parent; + let bullet = if parent.and_then(|node| self.tree.name(node)) == Some("ol") { + let parent = parent.unwrap(); + // ponytail: ASCII-only, like colspan below. Python's int() also + // parses Unicode decimal digits (Arabic-Indic, etc.), which std + // can't decode without a unicode-data crate — a marginal `start` + // value not worth the dependency. Upgrade: add `unicode` and map + // Nd digits if a golden ever needs it. + let start = self + .tree + .attr(parent, "start") + .filter(|value| !value.is_empty() && value.chars().all(|c| c.is_ascii_digit())) + .and_then(|value| value.parse::<usize>().ok()) + .unwrap_or(1); + let previous_items = self + .tree + .sibling_index(id) + .map(|(parent, index)| { + self.tree.nodes[parent].children[..index] + .iter() + .filter(|node| self.tree.name(**node) == Some("li")) + .count() + }) + .unwrap_or(0); + format!("{}.", start + previous_items) + } else { + let mut depth: isize = -1; + let mut cursor = Some(id); + while let Some(node) = cursor { + if self.tree.name(node) == Some("ul") { + depth += 1; + } + cursor = self.tree.nodes[node].parent; + } + ['*', '+', '-'][depth.rem_euclid(3) as usize].to_string() + }; + let marker = format!("{bullet} "); + let indent = " ".repeat(marker.len()); + let indented = indent_lines(text, &indent, ""); + format!("{marker}{}\n", &indented[marker.len()..]) + } + + fn convert_p(&self, text: String, parent_tags: &HashSet<String>) -> String { + if parent_tags.contains("_inline") { + return format!(" {} ", trim_ascii_markdown(&text)); + } + let text = trim_ascii_markdown(&text); + if text.is_empty() { + String::new() + } else { + format!("\n\n{text}\n\n") + } + } + + fn convert_pre(&self, text: String) -> String { + if text.is_empty() { + return String::new(); + } + // markdownify's STRIP mode: remove everything through the last + // leading newline (but preserve indentation after it), then remove + // all trailing spaces/newlines. + let start = text + .char_indices() + .take_while(|(_, c)| matches!(c, ' ' | '\n')) + .filter(|(_, c)| *c == '\n') + .map(|(index, _)| index + 1) + .last() + .unwrap_or(0); + let text = text[start..].trim_end_matches([' ', '\n']); + format!("\n\n```\n{text}\n```\n\n") + } + + fn convert_cell(&self, id: usize, text: String) -> String { + let colspan = self + .tree + .attr(id, "colspan") + .filter(|value| value.chars().all(|c| c.is_ascii_digit())) + .and_then(|value| value.parse::<usize>().ok()) + .unwrap_or(1) + .clamp(1, 1000); + format!( + " {}{}", + text.trim().replace('\n', " "), + " |".repeat(colspan) + ) + } + + fn convert_row(&self, id: usize, text: String) -> String { + let mut cells = Vec::new(); + self.tree + .element_children_recursive(id, &["td", "th"], &mut cells); + let is_first_row = self.tree.previous_element_sibling(id).is_none(); + let parent = self.tree.nodes[id].parent.unwrap_or(0); + let parent_name = self.tree.name(parent).unwrap_or(""); + let is_headrow = cells.iter().all(|cell| self.tree.name(*cell) == Some("th")) + || (parent_name == "thead" && { + let mut rows = Vec::new(); + self.tree + .element_children_recursive(parent, &["tr"], &mut rows); + rows.len() == 1 + }); + let table_has_thead = self.tree.nodes[parent].parent.is_some_and(|table| { + let mut heads = Vec::new(); + self.tree + .element_children_recursive(table, &["thead"], &mut heads); + !heads.is_empty() + }); + let head_missing = (is_first_row && parent_name != "tbody") + || (is_first_row && parent_name == "tbody" && !table_has_thead); + let full_colspan: usize = cells + .iter() + .map(|cell| { + self.tree + .attr(*cell, "colspan") + .filter(|value| value.chars().all(|c| c.is_ascii_digit())) + .and_then(|value| value.parse::<usize>().ok()) + .unwrap_or(1) + .clamp(1, 1000) + }) + .sum(); + let mut overline = String::new(); + let mut underline = String::new(); + if is_headrow && is_first_row { + underline = format!("| {} |\n", vec!["---"; full_colspan].join(" | ")); + } else { + let tbody_at_table_start = + parent_name == "tbody" && self.tree.previous_element_sibling(parent).is_none(); + if head_missing || (is_first_row && (parent_name == "table" || tbody_at_table_start)) { + overline.push_str(&format!("| {} |\n", vec![""; full_colspan].join(" | "))); + overline.push_str(&format!("| {} |\n", vec!["---"; full_colspan].join(" | "))); + } + } + format!("{overline}|{text}\n{underline}") + } +} + +fn markdownify(content: &str) -> String { + let tree = parse_markdown_tree(content); + MarkdownConverter { tree: &tree }.process() +} + +/// One target's rendering, `extraction_type` mode (shell.py). A +/// `QueryResult::Text` (a `::text`/`::attr()` pseudo-match) short-circuits +/// exactly like Python's text-node `Selector`: both `html_content` and +/// `get_all_text` return the bare value with no re-serialization +/// (`parser.py` — `if self._is_text_node(self._root): return ...`), so +/// `html`/`markdown` treat the value as their "outer html" and `text` skips +/// straight past `get_all_text` to collapsing the value itself. +/// +/// Oracle-probed (`li a::text` / `a::attr(href)` on basic.html): all three +/// modes give the identical bare value there, because it contains no HTML or +/// markdown-special characters. Markdown mode still runs the value through +/// the converter rather than passing it through raw, because markdownify +/// escapes markdown-significant characters even in a tag-less string — +/// reprobed with `1. *starred* & _under_ # hash`: +/// `markdownify` gives `1. \*starred\* & \_under\_ # hash`; the local port is +/// still run for this tag-less input because escaping is part of its contract. +fn render_one(target: &query::QueryResult, format: &str) -> Result<String, String> { + if format == "text" { + let raw = match target { + query::QueryResult::Element(el) => dom::get_all_text( + *el, + "\n", + true, + &["script", "style", "noscript", "svg", "iframe"], + true, + ), + query::QueryResult::Text { value, .. } => value.clone(), + }; + return Ok(collapse_whitespace(raw)); + } + let content_source = match target { + query::QueryResult::Element(el) => dom::outer_html(*el), + query::QueryResult::Text { value, .. } => value.clone(), + }; + match format { + "html" => Ok(content_source), + "markdown" => Ok(markdownify(&content_source)), + _ => unreachable!("format validated by render()"), + } +} + +/// `Convertor._extract_content` + `render_content`'s `"".join(...).strip()` +/// (shell.py / core.py, normative): validate format; optionally re-scope to +/// `body` and sanitize a clone; optionally scope further to every +/// `css_selector` match (concatenated, no separator; `::text`/`::attr()` +/// pseudo-matches render too, see `render_one`); render each target in +/// `format`; join and trim once at the end. +pub fn render( + doc: &Doc, + format: &str, + css_selector: Option<&str>, + main_content_only: bool, +) -> Result<String, String> { + if !matches!(format, "markdown" | "text" | "html") { + return Err(format!("unsupported format: {format}")); + } + + // Resolve the scope root on the ORIGINAL tree first — `body_or_root`'s + // tag lookup doesn't depend on sanitization — then clone and sanitize + // with that id exempted (see `sanitize_main_content`'s doc comment). + // NodeIds are stable across `Document::clone()`, so the id found here + // still names the same node in the clone. + let sanitized = main_content_only.then(|| { + let scope_id = body_or_root(doc).id(); + let mut sanitized = doc.clone(); + sanitize_main_content(&mut sanitized, scope_id); + (sanitized, scope_id) + }); + let (active_doc, root): (&Doc, ElementRef) = match &sanitized { + Some((d, id)) => (d, d.element(*id).expect("body_or_root remains an element")), + None => (doc, doc.root()), + }; + + let targets: Vec<query::QueryResult> = match css_selector.filter(|s| !s.is_empty()) { + Some(sel) => query::css_query(active_doc, Some(root), sel)?, + None => vec![query::QueryResult::Element(root)], + }; + + let mut content = String::new(); + for t in &targets { + content.push_str(&render_one(t, format)?); + } + Ok(content.trim().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scrapling::dom; + + #[test] + fn markdown_contains_heading_and_list() { + let d = dom::parse(r#"<html><body><h1>Hello</h1><ul><li>a</li></ul></body></html>"#); + let md = render(&d, "markdown", None, false).unwrap(); + assert!(md.contains("Hello")); + assert!(md.contains("a")); + } + + #[test] + fn hidden_and_zero_width_stripping() { + let d = dom::parse(concat!( + r#"<html><body><p>keep</p><p style="display:none">drop</p>"#, + "<p>ze\u{200b}ro</p></body></html>" + )); + let txt = render(&d, "text", None, true).unwrap(); + assert!(txt.contains("keep")); + assert!(!txt.contains("drop")); + assert!(txt.contains("zero")); + } + + #[test] + fn bad_format_error_matches_python() { + let d = dom::parse("<p>x</p>"); + assert_eq!( + render(&d, "pdf", None, false).unwrap_err(), + "unsupported format: pdf" + ); + } + + /// `_HIDDEN_XPATH` is `.//*[@aria-hidden='true']` — descendant axis, self + /// excluded — so the scope root carrying the attribute must not drop + /// itself. Oracle-confirmed: `{"content": "keep me"}`, not `""`. + #[test] + fn body_aria_hidden_does_not_drop_itself() { + let d = dom::parse(r#"<html><body aria-hidden="true"><p>keep me</p></body></html>"#); + assert_eq!(render(&d, "text", None, true).unwrap(), "keep me"); + } + + /// Same exemption, via the style-substring half of `_HIDDEN_XPATH` + /// (`.//*[contains(@style, "display:none") ...]`). Oracle-confirmed: + /// `{"content": "keep me too"}`. + #[test] + fn body_style_hidden_does_not_drop_itself() { + let d = dom::parse(r#"<html><body style="display:none"><p>keep me too</p></body></html>"#); + assert_eq!(render(&d, "text", None, true).unwrap(), "keep me too"); + } +} diff --git a/browser/src/scrapling/mod.rs b/browser/src/scrapling/mod.rs new file mode 100644 index 000000000..f6c247a1d --- /dev/null +++ b/browser/src/scrapling/mod.rs @@ -0,0 +1,260 @@ +//! The `browser::*` surface: scrapling's parse-only functions, +//! natively. `register_all` binds the 19 parse functions and the guidance hook +//! function on the iii bus; `apply_guidance` binds the guidance hook to +//! `harness::hook::pre-generate`. +//! +//! Wire contract: schemas byte-identical to scrapling/src/schemas.py modulo the +//! `browser::` id prefix (golden-pinned); behavior differential-pinned against +//! the frozen Scrapling 0.4.9 worker. +//! +//! Registration deliberately stays separate from `crate::functions` +//! (the 29 `browser::*` functions): that module pins its surface with +//! schemars-derived schemas and self-regenerating goldens, while this one +//! carries Python-mirrored literals checked against read-only goldens. Two +//! catalogs, two invariants, no interleaving. + +pub mod adaptive; +mod browserforge; +pub mod cdp; +pub mod crawl; +pub mod dom; +pub mod egress_gate; +pub mod fetch; +pub mod inject_guidance; +pub mod markdown; +pub mod net; +pub mod ops; +pub mod page; +pub mod query; +pub mod raw_browser; +pub mod schemas; +pub mod selgen; +pub mod sessions; +pub mod similar; +pub mod text; +pub mod xpath; + +use std::sync::Arc; + +use serde_json::Value; + +pub(crate) fn require_str<'a>(payload: &'a Value, key: &str) -> Result<&'a str, String> { + payload + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| format!("'{key}'")) +} + +/// The 19 catalog function ids (in `schemas::catalog()` order) plus the +/// internal guidance hook, last. `register_all` asserts it registers exactly +/// this set, in this order. +pub const STATIC_IDS: &[&str] = &[ + "browser::fetch", + "browser::stealthy-fetch", + "browser::dynamic-fetch", + "browser::screenshot-url", + "browser::extract", + "browser::css", + "browser::xpath", + "browser::regex", + "browser::find-similar", + "browser::find", + "browser::find-by-text", + "browser::find-by-regex", + "browser::describe", + "browser::to-markdown", + "browser::session-open", + "browser::session-fetch", + "browser::session-close", + "browser::session-list", + "browser::crawl", + inject_guidance::GUIDANCE_HOOK_ID, +]; + +/// An op's dispatch fn pointer (named alias: clippy::type_complexity on the +/// bare `fn(&Value) -> Result<Value, String>` spelled out at every use site). +type Op = fn(&Value) -> Result<Value, String>; + +/// Look up the op fn for a catalog function id. `None` for anything not in +/// the catalog (including the guidance hook, which isn't dispatched here). +pub fn op_for(id: &str) -> Option<Op> { + Some(match id { + "browser::extract" => ops::extract::op, + "browser::css" => ops::css_fn::op, + "browser::xpath" => ops::xpath_fn::op, + "browser::regex" => ops::regex_fn::op, + "browser::find-similar" => ops::find_similar::op, + "browser::find" => ops::find::op, + "browser::find-by-text" => ops::find_by_text::op, + "browser::find-by-regex" => ops::find_by_regex::op, + "browser::describe" => ops::describe::op, + "browser::to-markdown" => ops::to_markdown::op, + _ => return None, + }) +} + +pub fn dispatch_op(function_id: &str, payload: &Value) -> Result<Value, String> { + match op_for(function_id) { + Some(op) => op(payload), + None => Err(format!("not implemented: {function_id}")), + } +} + +/// Register all 19 `browser::*` functions plus the internal +/// guidance hook on the iii bus. +/// +/// The catalog is served by two handler families and every entry belongs to +/// exactly one: the ten parse ops are sync and stateless +/// (`fn(&Value) -> Result<Value>`, found via `op_for`), while the nine +/// outbound ones are async and need `net::Ctx`. Both take and return untyped +/// `serde_json::Value` — schemars would derive the permissive `AnyValue` +/// schema for that — so the `.request_format`/`.response_format` overrides +/// carrying `schemas::catalog()`'s Python-mirrored literals are mandatory. +/// The guidance hook uses real typed structs and relies on derivation. +pub fn register_all(iii: &Arc<iii_sdk::IIIClient>, ctx: &Arc<net::Ctx>) { + let mut registered: Vec<&str> = Vec::new(); + for spec in crate::scrapling::schemas::catalog() { + registered.push(spec.function_id); + match op_for(spec.function_id) { + Some(op) => { + iii.register_function( + spec.function_id, + iii_sdk::RegisterFunction::new_async( + move |payload: serde_json::Value| async move { + // Parse ops are synchronous and CPU-bound; some + // (regex/find-by-regex on the vendored sre_engine, + // which has no backtracking limit) can run for a + // long time on adversarial input. Run them off the + // async runtime so one call can't peg a runtime + // worker and stall the interactive functions and + // outbound fetches that share it. + tokio::task::spawn_blocking(move || op(&payload)) + .await + .map_err(|e| iii_sdk::Error::from(format!("parse op failed: {e}")))? + .map_err(iii_sdk::Error::from) + }, + ) + .description(spec.description) + .request_format(spec.request.clone()) + .response_format(spec.response.clone()), + ); + } + None => { + let id = spec.function_id; + let cx = ctx.clone(); + iii.register_function( + id, + iii_sdk::RegisterFunction::new_async(move |payload: serde_json::Value| { + let cx = cx.clone(); + async move { + net::dispatch(&cx, id, &payload) + .await + .map_err(iii_sdk::Error::from) + } + }) + .description(spec.description) + .request_format(spec.request.clone()) + .response_format(spec.response.clone()), + ); + } + } + } + registered.push(inject_guidance::GUIDANCE_HOOK_ID); + iii.register_function( + inject_guidance::GUIDANCE_HOOK_ID, + iii_sdk::RegisterFunction::new_async( + |event: inject_guidance::PreGenerateEvent| async move { + inject_guidance::handle(event).await + }, + ) + .description(inject_guidance::GUIDANCE_HOOK_DESC) + .metadata(serde_json::json!({ "internal": true })), + ); + assert_eq!( + registered, STATIC_IDS, + "register_all must register exactly STATIC_IDS" + ); + tracing::info!("browser::* functions registered"); +} + +/// The live pre-generate guidance binding, if any. Shared with the +/// configuration change handler so a `browser.scrapling.inject_guidance` +/// flip binds/unbinds at runtime — no worker restart (mirrors +/// fp/src/guidance.rs). +pub type GuidanceState = iii_config_client::BindingSlot; + +/// Reconcile the guidance binding with the configured `inject_guidance` +/// value: on → bind once; off → unregister and drop the handle. Idempotent +/// under repeated config events; a failed bind retries on the next event. +/// The hook FUNCTION stays registered either way (`register_all`) — an +/// unbound function is inert, and keeping it registered is what lets a +/// config flip enable injection without a restart. +/// +/// `on_error: fail_open` is MANDATORY: pre_generate defaults fail-CLOSED, +/// and a missing guidance line must never abort a turn. +/// `metadata.inject_prompt` carries the guidance statically so the harness +/// appends it without an RPC per generate step; without it every model-call +/// step makes a live call to this worker and blocks generation on the answer +/// (and the guidance vanishes from the frozen-prompt preview). +pub fn apply_guidance(iii: &iii_sdk::IIIClient, state: &GuidanceState, enabled: bool) { + state.reconcile( + enabled, + || { + iii_config_client::try_bind( + iii, + iii_sdk::protocol::RegisterTriggerInput { + trigger_type: "harness::hook::pre-generate".to_string(), + function_id: inject_guidance::GUIDANCE_HOOK_ID.to_string(), + config: serde_json::json!({ "on_error": "fail_open" }), + metadata: Some( + serde_json::json!({ "inject_prompt": inject_guidance::GUIDANCE }), + ), + }, + ) + }, + "inject_guidance on: appending browser::* scraping guidance to agent system prompts", + "inject_guidance off: browser::* scraping guidance stays out of agent system prompts", + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `register_all` picks a handler family per catalog entry. If an id + /// belonged to neither (or both), registration would silently ship a + /// function that always answers "not implemented" — so pin the partition + /// here rather than discovering it on the bus. + #[test] + fn every_catalog_id_has_exactly_one_handler_family() { + for spec in schemas::catalog() { + let sync = op_for(spec.function_id).is_some(); + let net = net::NET_IDS.contains(&spec.function_id); + assert!( + sync ^ net, + "{} must be handled by exactly one family (sync parse op: {sync}, net: {net})", + spec.function_id + ); + } + } + + #[test] + fn static_ids_are_the_catalog_in_order_plus_the_hook() { + let mut expected: Vec<&str> = schemas::catalog().iter().map(|s| s.function_id).collect(); + expected.push(inject_guidance::GUIDANCE_HOOK_ID); + assert_eq!(STATIC_IDS, expected.as_slice()); + } + + #[test] + fn net_ids_covers_every_non_parse_catalog_entry() { + let catalog_net: Vec<&str> = schemas::catalog() + .iter() + .map(|s| s.function_id) + .filter(|id| op_for(id).is_none()) + .collect(); + assert_eq!(catalog_net.len(), net::NET_IDS.len()); + for id in &catalog_net { + assert!(net::NET_IDS.contains(id), "{id} missing from NET_IDS"); + } + } +} diff --git a/browser/src/scrapling/net.rs b/browser/src/scrapling/net.rs new file mode 100644 index 000000000..6c621589b --- /dev/null +++ b/browser/src/scrapling/net.rs @@ -0,0 +1,524 @@ +//! The nine outbound `browser::*` functions: the fetch tiers, +//! screenshot, persistent sessions and crawl. +//! +//! Unlike the ten parse ops (sync, stateless, `fn(&Value) -> Result<Value>`), +//! these are async and need state — an HTTP session registry, the browser +//! session registry, config and the bus — so they carry a `Ctx` and register +//! through their own path in `super::register_all`. + +use std::sync::Arc; + +use futures::StreamExt; +use serde_json::{json, Value}; + +use crate::config::{SecurityMode, SharedConfig, WorkerConfig}; +use crate::scrapling::crawl::{self, CrawlOpts}; +use crate::scrapling::fetch::{self, HttpMode, HttpOptions}; +use crate::scrapling::page::{self, PageData}; +use crate::scrapling::raw_browser::{RawBrowser, RawBrowserOptions}; +use crate::scrapling::sessions::{parse_type, uses_compat_only_options, Registry}; +use crate::session::Sessions; +use crate::ssrf::SsrfPolicy; + +pub struct Ctx { + pub http: Registry, + pub config: SharedConfig, + pub iii: Arc<iii_sdk::IIIClient>, +} + +impl Ctx { + pub fn new(sessions: Arc<Sessions>, iii: Arc<iii_sdk::IIIClient>) -> Self { + let config = sessions.config.clone(); + let startup = config.load().scrapling.startup_snapshot(); + Self { + http: Registry::new(startup.max_sessions, startup.session_idle_timeout_s), + config, + iii, + } + } + + pub fn policy(&self) -> SsrfPolicy { + SsrfPolicy { + allow_loopback: self.config.load().scrapling.allow_loopback, + } + } +} + +/// Run one fetch per target URL, single or bulk, isolating per-URL failures. +async fn fetch_targets<F, Fut>( + payload: &Value, + concurrency: usize, + fetch_one: F, +) -> Result<Value, String> +where + F: Fn(String) -> Fut + Sync, + Fut: std::future::Future<Output = Result<PageData, String>>, +{ + let (urls, bulk) = page::targets(payload)?; + let include_html = page::include_html(payload); + if !bulk { + let p = fetch_one(urls[0].clone()).await?; + return page::serialize_page(&p, payload, include_html); + } + let out = futures::stream::iter(urls.into_iter().map(|url| { + let fetch_one = &fetch_one; + async move { + match fetch_one(url.clone()).await { + Ok(page) => page::serialize_page(&page, payload, include_html) + .map_err(|error| (url.clone(), error)), + Err(error) => Err((url, error)), + } + } + })) + .buffered(concurrency) + .collect::<Vec<_>>() + .await; + Ok(page::bulk_results(out)) +} + +fn one_shot_payload(config: &WorkerConfig, payload: &Value, include_html_default: bool) -> Value { + let mut request = payload.as_object().cloned().unwrap_or_default(); + let defaults = &config.scrapling.defaults; + // Safe mode refuses every proxy, so injecting the config default would + // fail every outbound call with an error blaming a "caller proxy" the + // caller never sent. The knob is compat-only; safe mode leaves it out and + // a caller-passed proxy still gets the explicit refusal. + let inject_proxy = config.scrapling.security_mode == SecurityMode::Compat; + for (key, value) in [ + ("impersonate", json!(defaults.impersonate)), + ("headless", json!(defaults.headless)), + ("network_idle", json!(defaults.network_idle)), + ("proxy", json!(defaults.proxy)), + ] { + if key == "proxy" && !inject_proxy { + continue; + } + if request.get(key).is_none_or(Value::is_null) && value != json!("") { + request.insert(key.to_string(), value); + } + } + if include_html_default && request.get("include_html").is_none_or(Value::is_null) { + request.insert("include_html".to_string(), json!(defaults.include_html)); + } + Value::Object(request) +} + +fn http_mode(ctx: &Ctx) -> HttpMode { + match ctx.config.load().scrapling.security_mode { + SecurityMode::Safe => HttpMode::Safe, + SecurityMode::Compat => HttpMode::Compat, + } +} + +async fn raw_browser_targets(ctx: &Ctx, payload: &Value, stealth: bool) -> Result<Value, String> { + let config = ctx.config.load_full(); + let request = one_shot_payload(&config, payload, true); + let mut options = RawBrowserOptions::from_payload(&request)?; + options.clamp_durations(config.max_timeout_ms); + options.validate_policy(config.scrapling.security_mode)?; + let concurrency = usize::try_from(config.scrapling.max_bulk_concurrency) + .unwrap_or(usize::MAX) + .max(1); + let (urls, bulk) = page::targets(&request)?; + let include_html = page::include_html(&request); + if !bulk { + let browser = RawBrowser::start(&config, &options, stealth, false).await?; + let fetched = browser.fetch(&urls[0], &options, stealth).await?; + return page::serialize_page(&fetched, &request, include_html); + } + let output = futures::stream::iter(urls.into_iter().map(|url| { + let config = config.clone(); + let options = options.clone(); + let request = request.clone(); + async move { + let fetched = async { + let browser = RawBrowser::start(&config, &options, stealth, false).await?; + browser.fetch(&url, &options, stealth).await + } + .await; + match fetched { + Ok(page) => page::serialize_page(&page, &request, include_html) + .map_err(|error| (url.clone(), error)), + Err(error) => Err((url, error)), + } + } + })) + .buffered(concurrency) + .collect::<Vec<_>>() + .await; + Ok(page::bulk_results(output)) +} + +// ---- the nine handlers --------------------------------------------------- + +pub async fn op_fetch(ctx: &Ctx, payload: &Value) -> Result<Value, String> { + let config = ctx.config.load_full(); + let request = one_shot_payload(&config, payload, true); + let opts = HttpOptions::from_payload_for_mode(&request, http_mode(ctx))?; + let policy = ctx.policy(); + let concurrency = usize::try_from(config.scrapling.max_bulk_concurrency) + .unwrap_or(usize::MAX) + .max(1); + fetch_targets(&request, concurrency, |url| { + let opts = &opts; + let policy = &policy; + async move { fetch::fetch_page(&url, opts, policy).await } + }) + .await +} + +pub async fn op_dynamic_fetch(ctx: &Ctx, payload: &Value) -> Result<Value, String> { + raw_browser_targets(ctx, payload, false).await +} + +pub async fn op_stealthy_fetch(ctx: &Ctx, payload: &Value) -> Result<Value, String> { + raw_browser_targets(ctx, payload, true).await +} + +pub async fn op_screenshot(ctx: &Ctx, payload: &Value) -> Result<Value, String> { + let cfg = ctx.config.load_full(); + let request = one_shot_payload(&cfg, payload, false); + let url = request + .get("url") + .and_then(Value::as_str) + .filter(|u| !u.is_empty()) + .ok_or("provide `url`")?; + let fetcher = request + .get("fetcher") + .and_then(Value::as_str) + .unwrap_or("dynamic"); + let format = request + .get("format") + .and_then(Value::as_str) + .unwrap_or("png"); + let full_page = request + .get("full_page") + .and_then(Value::as_bool) + .unwrap_or(false); + + let mut options = RawBrowserOptions::from_payload(&request)?; + options.clamp_durations(cfg.max_timeout_ms); + let browser = RawBrowser::start(&cfg, &options, fetcher == "stealthy", false).await?; + let (content, mime, final_url) = browser + .screenshot(url, &options, fetcher == "stealthy", full_page, format) + .await?; + Ok(json!({"content": content, "mime": mime, "url": final_url})) +} + +pub async fn op_session_open(ctx: &Ctx, payload: &Value) -> Result<Value, String> { + let stype = parse_type(payload)?; + let mode = ctx.config.load().scrapling.security_mode; + let compat_only = uses_compat_only_options(payload) || mode == SecurityMode::Compat; + if compat_only && mode == SecurityMode::Safe { + return Err( + "session options require browser.scrapling.security_mode=compat; remove them or switch modes" + .to_string(), + ); + } + if stype == crate::scrapling::sessions::SessionType::Http { + ctx.http + .open_http(payload, compat_only, http_mode(ctx)) + .await + } else { + ctx.http + .open_browser(stype, payload, compat_only, ctx.config.load_full()) + .await + } +} + +pub async fn op_session_fetch(ctx: &Ctx, payload: &Value) -> Result<Value, String> { + let sid = payload + .get("session_id") + .and_then(Value::as_str) + .ok_or("provide `session_id`")?; + let url = payload + .get("url") + .and_then(Value::as_str) + .filter(|u| !u.is_empty()) + .ok_or("provide `url`")? + .to_string(); + validate_session_mode( + ctx.http.uses_compat_only_options(sid)?, + ctx.config.load().scrapling.security_mode, + )?; + match ctx.http.session_type(sid)? { + crate::scrapling::sessions::SessionType::Http => { + let backend = ctx.http.http_backend(sid)?; + let request = backend.request(payload); + let include_html = page::include_html(&request); + let policy = ctx.policy(); + ctx.http + .run( + sid, + Box::pin(async move { + let mut options = + HttpOptions::from_payload_for_mode(&request, backend.mode)?; + options.jar = Some(backend.jar.clone()); + #[cfg(feature = "scrapling-compat")] + { + options.compat_session = backend.compat.clone(); + } + let fetched = fetch::fetch_page(&url, &options, &policy).await?; + page::serialize_page(&fetched, &request, include_html) + }), + ) + .await + } + crate::scrapling::sessions::SessionType::Dynamic + | crate::scrapling::sessions::SessionType::Stealthy => { + let backend = ctx.http.browser_backend(sid)?; + let request = backend.request(payload); + let proxy_override = payload + .get("proxy") + .filter(|value| !value.is_null()) + .is_some_and(|value| value.as_str() != Some("")); + let include_html = page::include_html(&request); + let security_mode = backend.security_mode; + let max_timeout_ms = ctx.config.load().max_timeout_ms; + ctx.http + .run( + sid, + Box::pin(async move { + let mut options = RawBrowserOptions::from_payload(&request)?; + options.clamp_durations(max_timeout_ms); + options.validate_policy(security_mode)?; + let fetched = backend + .browser + .fetch_session(&url, &options, backend.stealth, proxy_override) + .await?; + page::serialize_page(&fetched, &request, include_html) + }), + ) + .await + } + } +} + +fn validate_session_mode(compat_only: bool, mode: SecurityMode) -> Result<(), String> { + if compat_only && mode == SecurityMode::Safe { + return Err( + "this session uses compat-only options and safe mode is now active; close and reopen the session" + .to_string(), + ); + } + Ok(()) +} + +pub async fn op_session_close(ctx: &Ctx, payload: &Value) -> Result<Value, String> { + let sid = payload + .get("session_id") + .and_then(Value::as_str) + .ok_or("provide `session_id`")?; + Ok(ctx.http.close(sid).await) +} + +pub async fn op_session_list(ctx: &Ctx, payload: &Value) -> Result<Value, String> { + Ok(ctx.http.list(payload.get("type"))) +} + +pub async fn op_crawl(ctx: &Ctx, payload: &Value) -> Result<Value, String> { + let cfg = ctx.config.load_full(); + let opts = CrawlOpts::from_payload_for_mode( + payload, + cfg.scrapling.max_bulk_concurrency as usize, + cfg.scrapling.security_mode, + )?; + let (group_id, group_id_text) = crawl_group_id(payload); + + let http = opts.fetcher == "http"; + let policy = ctx.policy(); + let mode = ctx.config.load().scrapling.security_mode; + // Atomic, not Cell: this future is handed to the SDK, which requires Send + // + Sync. + let seq = std::sync::atomic::AtomicUsize::new(0); + + let outcome = crawl::run( + &opts, + payload, + |url| { + let cfg = cfg.clone(); + let policy = &policy; + let stealth = opts.fetcher == "stealthy"; + let request = one_shot_payload(&cfg, &crawl::fetch_payload(payload, &url), false); + async move { + if http { + let options = HttpOptions::from_payload_for_mode( + &request, + match mode { + SecurityMode::Safe => HttpMode::Safe, + SecurityMode::Compat => HttpMode::Compat, + }, + )?; + fetch::fetch_page(&url, &options, policy).await + } else { + let mut options = RawBrowserOptions::from_payload(&request)?; + options.clamp_durations(cfg.max_timeout_ms); + options.validate_policy(cfg.scrapling.security_mode)?; + let browser = RawBrowser::start(&cfg, &options, stealth, false).await?; + browser.fetch(&url, &options, stealth).await + } + } + }, + |item| { + let n = seq.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + let iii = ctx.iii.clone(); + let payload = json!({ + "stream_name": opts.stream_name, + "group_id": group_id, + "item_id": format!("{group_id_text}-{n:06}"), + "data": item, + }); + Box::pin(async move { + let res = iii + .trigger(iii_sdk::protocol::TriggerRequest { + function_id: "stream::set".to_string(), + payload, + action: None, + timeout_ms: Some(5_000), + }) + .await; + if let Err(e) = res { + tracing::debug!(error = %e, "crawl stream::set failed; continuing"); + } + }) + }, + ) + .await; + + Ok(json!({ + "stats": { + "crawled": outcome.crawled, + "items": outcome.item_count, + "errors": outcome.errors, + "stopped": outcome.stopped, + }, + "items": outcome.items, + "stream": {"name": opts.stream_name, "group_id": group_id}, + })) +} + +fn crawl_group_id(payload: &Value) -> (Value, String) { + if let Some(value) = payload + .get("group_id") + .filter(|value| crawl::json_truthy(value)) + { + let text = match value { + Value::String(value) => value.clone(), + Value::Bool(true) => "True".into(), + Value::Bool(false) | Value::Null => unreachable!("falsy values were filtered"), + other => crawl::python_repr(other), + }; + return (value.clone(), text); + } + let id = uuid::Uuid::new_v4().simple().to_string(); + (json!(id), id) +} + +/// Route one of the nine async function ids to its handler. The ten sync +/// parse ops go through `super::op_for` instead; `super::register_all` picks +/// the path per catalog entry. +pub async fn dispatch(ctx: &Ctx, function_id: &str, payload: &Value) -> Result<Value, String> { + match function_id { + "browser::fetch" => op_fetch(ctx, payload).await, + "browser::dynamic-fetch" => op_dynamic_fetch(ctx, payload).await, + "browser::stealthy-fetch" => op_stealthy_fetch(ctx, payload).await, + "browser::screenshot-url" => op_screenshot(ctx, payload).await, + "browser::session-open" => op_session_open(ctx, payload).await, + "browser::session-fetch" => op_session_fetch(ctx, payload).await, + "browser::session-close" => op_session_close(ctx, payload).await, + "browser::session-list" => op_session_list(ctx, payload).await, + "browser::crawl" => op_crawl(ctx, payload).await, + other => Err(format!("not implemented: {other}")), + } +} + +/// Every id `dispatch` handles, in catalog order. `super::register_all` +/// asserts this partitions the catalog exactly with `op_for`. +pub const NET_IDS: &[&str] = &[ + "browser::fetch", + "browser::stealthy-fetch", + "browser::dynamic-fetch", + "browser::screenshot-url", + "browser::session-open", + "browser::session-fetch", + "browser::session-close", + "browser::session-list", + "browser::crawl", +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_net_id_dispatches_somewhere() { + // A typo'd arm in `dispatch` would silently become "not implemented" + // at runtime; this pins the two lists together instead. + for id in NET_IDS { + assert!( + !matches!(id, &"") && crate::scrapling::op_for(id).is_none(), + "{id} must not also be a sync parse op" + ); + } + } + + #[test] + fn one_shot_defaults_apply_to_null_fields_but_crawl_html_stays_request_only() { + let mut config = WorkerConfig::default(); + config.scrapling.defaults.impersonate = "firefox".to_string(); + config.scrapling.defaults.headless = false; + config.scrapling.defaults.network_idle = true; + config.scrapling.defaults.proxy = "http://configured".to_string(); + config.scrapling.defaults.include_html = true; + let request = one_shot_payload( + &config, + &json!({"impersonate": null, "headless": true}), + true, + ); + assert_eq!(request["impersonate"], "firefox"); + assert_eq!(request["headless"], true); + assert_eq!(request["network_idle"], true); + assert_eq!(request["include_html"], true); + // Safe mode (the default) refuses every proxy, so the config default + // must NOT be injected — otherwise every fetch fails blaming a caller + // proxy nobody sent. Compat mode injects it. + assert!(request.get("proxy").is_none()); + config.scrapling.security_mode = SecurityMode::Compat; + let compat = one_shot_payload(&config, &json!({}), true); + assert_eq!(compat["proxy"], "http://configured"); + assert!(one_shot_payload(&config, &json!({}), false) + .get("include_html") + .is_none()); + } + + #[test] + fn crawl_group_id_honors_nonempty_input_or_generates_uuid4_hex() { + assert_eq!( + crawl_group_id(&json!({"group_id": "caller-group"})), + (json!("caller-group"), "caller-group".into()) + ); + assert_eq!( + crawl_group_id(&json!({"group_id": 3})), + (json!(3), "3".into()) + ); + for payload in [json!({}), json!({"group_id": ""})] { + let (value, generated) = crawl_group_id(&payload); + assert_eq!(value, generated); + assert_eq!(generated.len(), 32); + assert!(generated + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))); + assert_eq!(&generated[12..13], "4"); + } + } + + #[test] + fn safe_mode_invalidates_a_compat_only_session() { + assert_eq!( + validate_session_mode(true, SecurityMode::Safe).unwrap_err(), + "this session uses compat-only options and safe mode is now active; close and reopen the session" + ); + assert!(validate_session_mode(true, SecurityMode::Compat).is_ok()); + assert!(validate_session_mode(false, SecurityMode::Safe).is_ok()); + } +} diff --git a/browser/src/scrapling/ops/common.rs b/browser/src/scrapling/ops/common.rs new file mode 100644 index 000000000..8d7adac93 --- /dev/null +++ b/browser/src/scrapling/ops/common.rs @@ -0,0 +1,86 @@ +//! Shared response-shaping helpers (core.py `_bounded`, `serialize_element`). + +use serde_json::{json, Value}; + +use crate::scrapling::{dom, dom::ElementRef, selgen}; + +pub const MAX_FIND_ITEMS: usize = 100; + +pub fn bounded(total: usize, payload: &Value) -> usize { + if payload + .get("first") + .and_then(Value::as_bool) + .unwrap_or(false) + { + return total.min(1); + } + // `as_i64` is None for a JSON float or numeric string (an LLM emitting + // `"limit": 5.0` is common), which would silently fall back to the 100 + // cap; coerce those too before defaulting. + let limit = payload.get("limit").and_then(|v| { + v.as_i64() + .or_else(|| v.as_f64().map(|f| f as i64)) + .or_else(|| v.as_str().and_then(|s| s.trim().parse::<i64>().ok())) + }); + let ceiling = match limit { + // Clamp to [0, MAX]: limit 0 → none; negative must NOT slice from the end. + Some(l) => l.clamp(0, MAX_FIND_ITEMS as i64) as usize, + None => MAX_FIND_ITEMS, + }; + total.min(ceiling) +} + +pub fn serialize_element(el: ElementRef) -> Value { + json!({ + "tag": el.name(), + "text": dom::get_all_text(el, "\n", true, &["script", "style"], true), + "html": dom::outer_html(el), + "attrs": dom::attrs_json(el), + "css": selgen::css_selector(el), + "xpath": selgen::xpath_selector(el), + }) +} + +/// Shared `{"count", "items"}` tail for the find* ops: `count` is the +/// pre-cap total, `items` the `bounded` slice, serialized. +pub fn bounded_items_response(matches: &[ElementRef], payload: &Value) -> Value { + let cap = bounded(matches.len(), payload); + json!({ + "count": matches.len(), + "items": matches[..cap].iter().map(|e| serialize_element(*e)).collect::<Vec<_>>(), + }) +} + +/// core.py `_pull`: attr → attribute value or `null` (Text results: always +/// `null`, they have no attributes of their own); `html` → outer HTML (Text: +/// the string itself); else `get_all_text` (Text: the string itself). +pub fn pull( + result: &crate::scrapling::query::QueryResult, + attr: Option<&str>, + html: bool, +) -> Value { + match result { + crate::scrapling::query::QueryResult::Element(el) => { + if let Some(a) = attr { + el.attr(a).map(Into::into).unwrap_or(Value::Null) + } else if html { + Value::String(crate::scrapling::dom::outer_html(*el)) + } else { + Value::String(crate::scrapling::dom::get_all_text( + *el, + "\n", + false, + &["script", "style"], + true, + )) + } + } + crate::scrapling::query::QueryResult::Text { value, .. } => { + if attr.is_some() { + Value::Null + } else { + Value::String(value.clone()) + } + } + } +} diff --git a/browser/src/scrapling/ops/css_fn.rs b/browser/src/scrapling/ops/css_fn.rs new file mode 100644 index 000000000..e0c010a13 --- /dev/null +++ b/browser/src/scrapling/ops/css_fn.rs @@ -0,0 +1,70 @@ +//! scrapling::css — one CSS query over HTML; first-or-all; `attr` pulls an +//! attribute else text (core.py `op_query(payload, "css")`). + +use serde_json::{json, Value}; + +use crate::scrapling::{adaptive, dom, query}; +use crate::scrapling::{ops::common, require_str}; + +pub fn op(payload: &Value) -> Result<Value, String> { + let html = require_str(payload, "html")?; + let q = require_str(payload, "query")?; + let doc = dom::parse(html); + let adaptive_enabled = payload + .get("adaptive") + .and_then(Value::as_bool) + .unwrap_or(false); + let results = if adaptive_enabled { + let identifier = payload + .get("identifier") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or(q); + let auto_save = match payload.get("auto_save") { + None => true, + Some(value) => value.as_bool().unwrap_or(false), + }; + adaptive::css_query( + &doc, + None, + q, + payload.get("adaptive_domain").and_then(Value::as_str), + identifier, + auto_save, + )? + } else { + query::css_query(&doc, None, q)? + }; + let attr = payload + .get("attr") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()); + if payload + .get("first") + .and_then(Value::as_bool) + .unwrap_or(false) + { + Ok(json!({ "result": results.first().map(|r| common::pull(r, attr, false)) })) + } else { + Ok( + json!({ "result": results.iter().map(|r| common::pull(r, attr, false)).collect::<Vec<_>>() }), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn adaptive_adjacent_fields_without_adaptive_are_ignored() { + let out = op(&json!({ + "html": "<html><body><p>x</p></body></html>", + "query": "p", "first": true, + "auto_save": true, "adaptive_domain": "example.com", "identifier": "k" + })) + .unwrap(); + assert_eq!(out, json!({"result": "x"})); + } +} diff --git a/browser/src/scrapling/ops/describe.rs b/browser/src/scrapling/ops/describe.rs new file mode 100644 index 000000000..77397cf11 --- /dev/null +++ b/browser/src/scrapling/ops/describe.rs @@ -0,0 +1,80 @@ +//! scrapling::describe — full identity + structure of the first css/xpath +//! match (core.py `op_describe`). + +use serde_json::{json, Value}; + +use crate::scrapling::{dom, query, selgen}; +use crate::scrapling::{ops::common, require_str}; + +pub fn op(payload: &Value) -> Result<Value, String> { + let html = require_str(payload, "html")?; + let q = require_str(payload, "query")?; + let doc = dom::parse(html); + // Python is `kind.get("kind", "css") == "css"`: the "css" default applies + // ONLY to a MISSING key. An explicit `null` (or any non-"css" value) is + // not equal to "css", so it takes the xpath branch — match that. + let is_css = match payload.get("kind") { + None => true, + Some(value) => value.as_str() == Some("css"), + }; + let results = if is_css { + query::css_query(&doc, None, q)? + } else { + crate::scrapling::xpath::xpath_query(&doc, None, q)? + }; + let Some(first) = results.first() else { + return Ok(json!({"found": false})); + }; + let element = match first { + query::QueryResult::Element(el) => { + let parent = dom::parent_element(*el); + let mut e = common::serialize_element(*el); + let obj = e.as_object_mut().unwrap(); + obj.insert("full_css".into(), json!(selgen::full_css_selector(*el))); + obj.insert("full_xpath".into(), json!(selgen::full_xpath_selector(*el))); + obj.insert( + "classes".into(), + json!(el + .attr("class") + .unwrap_or("") + .split_whitespace() + .collect::<Vec<_>>()), + ); + obj.insert( + "parent_tag".into(), + parent.map(|p| json!(p.name())).unwrap_or(Value::Null), + ); + obj.insert("children".into(), json!(dom::element_children(*el).len())); + obj.insert( + "siblings".into(), + json!(parent + .map(|p| dom::element_children(p).len().saturating_sub(1)) + .unwrap_or(0)), + ); + e + } + query::QueryResult::Text { value, parent } => json!({ + "tag": "#text", + "text": value, "html": value, "attrs": {}, + "css": "", "xpath": "", + "full_css": "", "full_xpath": "", + "classes": [], + "parent_tag": parent.name(), + "children": 0, + "siblings": dom::element_children(*parent).len(), + }), + }; + Ok(json!({"found": true, "element": element})) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_match_is_exactly_found_false() { + let out = op(&serde_json::json!({"html": "<p>x</p>", "query": ".nope"})).unwrap(); + assert_eq!(out, serde_json::json!({"found": false})); + assert!(out.get("element").is_none()); + } +} diff --git a/browser/src/scrapling/ops/extract.rs b/browser/src/scrapling/ops/extract.rs new file mode 100644 index 000000000..f745138ee --- /dev/null +++ b/browser/src/scrapling/ops/extract.rs @@ -0,0 +1,125 @@ +//! scrapling::extract — a declarative selector list (css/xpath/regex, +//! attr/html/all) applied to one context element (core.py `apply_selectors` +//! + `op_extract`). Also the find-similar per-item projection (Task 15). + +use serde_json::{json, Value}; + +use crate::scrapling::{adaptive, dom, query, text}; +use crate::scrapling::{ops::common, require_str}; + +/// Precedence per spec: `regex` short-circuits css/xpath; else css beats +/// xpath; no query at all → `[]`/`null` by `all`. Empty-string +/// css/xpath/regex are falsy (Python `spec.get("css") or ...`), so they fall +/// through like an absent key rather than erroring on an empty selector. +pub fn apply_selectors( + doc: &dom::Doc, + ctx: dom::ElementRef<'_>, + specs: &[Value], +) -> Result<Value, String> { + apply_selectors_with_adaptive(doc, ctx, specs, false, None, false) +} + +fn apply_selectors_with_adaptive( + doc: &dom::Doc, + ctx: dom::ElementRef<'_>, + specs: &[Value], + adaptive_enabled: bool, + domain: Option<&str>, + auto_save: bool, +) -> Result<Value, String> { + let mut out = serde_json::Map::new(); + for spec in specs { + let name = spec + .get("name") + .and_then(Value::as_str) + .ok_or("'name'")? + .to_string(); + let want_all = spec.get("all").and_then(Value::as_bool).unwrap_or(false); + if let Some(pattern) = spec + .get("regex") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + // regex specs run on the CONTEXT element's text, not the query result. + let text = dom::get_all_text(ctx, "\n", false, &["script", "style"], true); + let re = text::compile(pattern, false)?; + out.insert( + name, + if want_all { + json!(re.findall(&text)) + } else { + json!(re.find_first(&text)) + }, + ); + continue; + } + let css = spec + .get("css") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()); + let xp = spec + .get("xpath") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()); + let results = match (css, xp, adaptive_enabled) { + (Some(q), _, true) => adaptive::css_query(doc, Some(ctx), q, domain, &name, auto_save)?, + (None, Some(q), true) => { + adaptive::xpath_query(doc, Some(ctx), q, domain, &name, auto_save)? + } + (Some(q), _, false) => query::css_query(doc, Some(ctx), q)?, + (None, Some(q), false) => crate::scrapling::xpath::xpath_query(doc, Some(ctx), q)?, + (None, None, _) => { + out.insert(name, if want_all { json!([]) } else { Value::Null }); + continue; + } + }; + let attr = spec + .get("attr") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()); + let html_flag = spec.get("html").and_then(Value::as_bool).unwrap_or(false); + out.insert( + name, + if want_all { + json!(results + .iter() + .map(|r| common::pull(r, attr, html_flag)) + .collect::<Vec<_>>()) + } else { + results + .first() + .map(|r| common::pull(r, attr, html_flag)) + .unwrap_or(Value::Null) + }, + ); + } + Ok(Value::Object(out)) +} + +pub fn op(payload: &Value) -> Result<Value, String> { + let html = require_str(payload, "html")?; + let doc = dom::parse(html); + let specs = payload + .get("selectors") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let adaptive_enabled = payload + .get("adaptive") + .and_then(Value::as_bool) + .unwrap_or(false); + let auto_save = match payload.get("auto_save") { + None => adaptive_enabled, + Some(value) => value.as_bool().unwrap_or(false), + }; + Ok(json!({ + "extracted": apply_selectors_with_adaptive( + &doc, + doc.root(), + &specs, + adaptive_enabled, + payload.get("adaptive_domain").and_then(Value::as_str), + auto_save, + )? + })) +} diff --git a/browser/src/scrapling/ops/find.rs b/browser/src/scrapling/ops/find.rs new file mode 100644 index 000000000..37cf7f7cd --- /dev/null +++ b/browser/src/scrapling/ops/find.rs @@ -0,0 +1,105 @@ +//! scrapling::find — BeautifulSoup-style tag/attrs/text-regex search. + +use serde_json::Value; + +use crate::scrapling::{dom, query, text}; +use crate::scrapling::{ops::common, require_str}; + +pub(crate) fn py_str(v: &Value) -> String { + match v { + Value::Bool(true) => "True".to_string(), + Value::Bool(false) => "False".to_string(), + Value::String(s) => s.clone(), + Value::Null => "None".to_string(), + other => other.to_string(), + } +} + +pub fn op(payload: &Value) -> Result<Value, String> { + let html = require_str(payload, "html")?; + // Python filters on truthiness (`if tag:`, `if text_regex:`): an empty + // string or empty list is the same as absent. `attrs: {}` already falls + // out empty below with no extra guard needed. + let tags: Vec<String> = match payload.get("tag") { + Some(Value::String(s)) if !s.is_empty() => vec![s.clone()], + Some(Value::Array(a)) => a + .iter() + .filter_map(Value::as_str) + .map(String::from) + .collect(), + _ => vec![], + }; + let attrs: Vec<(String, String)> = payload + .get("attrs") + .and_then(Value::as_object) + .map(|m| m.iter().map(|(k, v)| (k.clone(), py_str(v))).collect()) + .unwrap_or_default(); + let text_regex = payload + .get("text_regex") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()); + + if tags.is_empty() && attrs.is_empty() && text_regex.is_none() { + return Err("provide at least one of `tag`, `attrs`, `text_regex`".to_string()); + } + + let doc = dom::parse(html); + let mut matches: Vec<dom::ElementRef<'_>> = if !tags.is_empty() || !attrs.is_empty() { + let base: Vec<String> = if tags.is_empty() { + vec!["*".into()] + } else { + tags + }; + let selector_strs: Vec<String> = base + .iter() + .map(|t| { + let mut s = t.clone(); + for (k, v) in &attrs { + s.push_str(&format!("[{}=\"{}\"]", k, v.replace('"', "\\\""))); + } + s + }) + .filter(|s| s != "*") + .collect(); + if selector_strs.is_empty() { + // Python's `find_all` builds one selector per tag, drops any that's + // exactly the bare wildcard `"*"` (a lone `tag: "*"` with no attrs), + // and only calls `self.css(...)` if anything survives; an all-"*" + // request leaves the list empty and falls through to + // `below_elements` (XPath `.//*`, every descendant) rather than + // handing cssselect an empty, invalid selector string. + dom::descendant_elements(doc.root()) + } else { + let selector_str = selector_strs.join(", "); + query::css_query(&doc, None, &selector_str)? + .into_iter() + .filter_map(|result| match result { + query::QueryResult::Element(element) => Some(element), + query::QueryResult::Text { .. } => None, + }) + .collect() + } + } else { + dom::descendant_elements(doc.root()) + }; + + if let Some(pattern) = text_regex { + let re = text::compile(pattern, false)?; + matches.retain(|el| re.check_match(&dom::leading_text(*el))); + } + + Ok(common::bounded_items_response(&matches, payload)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn attr_values_coerce_like_python_str() { + assert_eq!(py_str(&serde_json::json!(true)), "True"); + assert_eq!(py_str(&serde_json::json!(false)), "False"); + assert_eq!(py_str(&serde_json::json!(5)), "5"); + assert_eq!(py_str(&serde_json::json!("x")), "x"); + } +} diff --git a/browser/src/scrapling/ops/find_by_regex.rs b/browser/src/scrapling/ops/find_by_regex.rs new file mode 100644 index 000000000..a404f5571 --- /dev/null +++ b/browser/src/scrapling/ops/find_by_regex.rs @@ -0,0 +1,36 @@ +//! scrapling::find-by-regex — leading-text regex search. + +use serde_json::Value; + +use crate::scrapling::{dom, text}; +use crate::scrapling::{ops::common, require_str}; + +pub fn op(payload: &Value) -> Result<Value, String> { + let html = require_str(payload, "html")?; + let pattern = require_str(payload, "pattern")?; + let case_sensitive = payload + .get("case_sensitive") + .and_then(Value::as_bool) + .unwrap_or(false); + let clean_match = payload + .get("clean_match") + .and_then(Value::as_bool) + .unwrap_or(true); + + let re = text::compile(pattern, !case_sensitive)?; + let doc = dom::parse(html); + let mut matches = Vec::new(); + for el in dom::descendant_elements(doc.root()) { + if !dom::first_text_run_nonblank(el) { + continue; + } + let mut node_text = dom::leading_text(el); + if clean_match { + node_text = text::clean(&node_text); + } + if re.check_match(&node_text) { + matches.push(el); + } + } + Ok(common::bounded_items_response(&matches, payload)) +} diff --git a/browser/src/scrapling/ops/find_by_text.rs b/browser/src/scrapling/ops/find_by_text.rs new file mode 100644 index 000000000..02625c211 --- /dev/null +++ b/browser/src/scrapling/ops/find_by_text.rs @@ -0,0 +1,75 @@ +//! scrapling::find-by-text — leading-text equality/containment search. + +use serde_json::Value; + +use crate::scrapling::{dom, text}; +use crate::scrapling::{ops::common, require_str}; + +pub fn op(payload: &Value) -> Result<Value, String> { + let html = require_str(payload, "html")?; + let query = require_str(payload, "text")?; + let partial = payload + .get("partial") + .and_then(Value::as_bool) + .unwrap_or(false); + let case_sensitive = payload + .get("case_sensitive") + .and_then(Value::as_bool) + .unwrap_or(false); + let clean_match = payload + .get("clean_match") + .and_then(Value::as_bool) + .unwrap_or(true); + + let query = if case_sensitive { + query.to_string() + } else { + query.to_lowercase() + }; + let doc = dom::parse(html); + let mut matches = Vec::new(); + for el in dom::descendant_elements(doc.root()) { + if !dom::first_text_run_nonblank(el) { + continue; + } + let mut node_text = dom::leading_text(el); + if clean_match { + node_text = text::clean(&node_text); + } + if !case_sensitive { + node_text = node_text.to_lowercase(); + } + let hit = if partial { + node_text.contains(&query) + } else { + node_text == query + }; + if hit { + matches.push(el); + } + } + Ok(common::bounded_items_response(&matches, payload)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn matches_leading_text_not_subtree() { + // <li> has no leading text (its text lives in <a>); the <a> matches. + let html = r#"<html><body><ul><li><a href="/a">Apple</a></li></ul></body></html>"#; + let out = op(&json!({"html": html, "text": "Apple"})).unwrap(); + assert_eq!(out["count"], 1); + assert_eq!(out["items"][0]["tag"], "a"); + } + + #[test] + fn count_is_precap_total_when_first() { + let html = r#"<html><body><p>x</p><p>x</p><p>x</p></body></html>"#; + let out = op(&json!({"html": html, "text": "x", "first": true})).unwrap(); + assert_eq!(out["count"], 3); + assert_eq!(out["items"].as_array().unwrap().len(), 1); + } +} diff --git a/browser/src/scrapling/ops/find_similar.rs b/browser/src/scrapling/ops/find_similar.rs new file mode 100644 index 000000000..3f8412b8c --- /dev/null +++ b/browser/src/scrapling/ops/find_similar.rs @@ -0,0 +1,97 @@ +//! scrapling::find-similar — structural auto-match: given one example +//! element (the first css match), return it plus elements judged alike by +//! `similar::find_similar`'s depth/tag-chain/attribute scoring (core.py +//! `op_find_similar`). No adaptive refusal: Python's `op_find_similar` never +//! reads `adaptive` either (same precedent as `describe`). + +use serde_json::{json, Value}; + +use crate::scrapling::{dom, query, similar}; +use crate::scrapling::{ops::extract, require_str}; + +pub fn op(payload: &Value) -> Result<Value, String> { + let html = require_str(payload, "html")?; + let anchor_query = require_str(payload, "anchor")?; + let doc = dom::parse(html); + let results = query::css_query(&doc, None, anchor_query)?; + let Some(first) = results.first() else { + return Ok(json!({"count": 0, "items": []})); + }; + + // A `::text`/`::attr()` anchor has no subtree to search: no similars, + // and — per the ledger — always the plain default item, even when + // `selectors` is given. (Python's `Selector` wraps text nodes too and + // would run `apply_selectors` against the bare string instead, but + // `apply_selectors` here takes an `ElementRef` ctx with nothing text-node + // shaped to pass it, no fixture exercises that combination, and + // `extract.rs` is outside this task's touched files.) + let anchor = match first { + query::QueryResult::Text { value, .. } => { + return Ok(json!({"count": 1, "items": [{"text": value, "html": value}]})); + } + query::QueryResult::Element(el) => *el, + }; + + let threshold = payload + .get("similarity_threshold") + .and_then(Value::as_f64) + .unwrap_or(0.2); + let match_text = payload + .get("match_text") + .and_then(Value::as_bool) + .unwrap_or(false); + + let mut elements = vec![anchor]; + elements.extend(similar::find_similar(&doc, anchor, threshold, match_text)); + + // Python truthiness: an empty `selectors` list is the same as absent. + let specs = payload + .get("selectors") + .and_then(Value::as_array) + .filter(|specs| !specs.is_empty()); + + let items = elements + .iter() + .map(|&el| match specs { + Some(specs) => extract::apply_selectors(&doc, el, specs), + None => Ok(default_item(el)), + }) + .collect::<Result<Vec<_>, String>>()?; + + Ok(json!({"count": items.len(), "items": items})) +} + +/// `_default_item`: note `strip=false` here — unlike `common::serialize_element`. +fn default_item(el: dom::ElementRef<'_>) -> Value { + json!({ + "text": dom::get_all_text(el, "\n", false, &["script", "style"], true), + "html": dom::outer_html(el), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_anchor_has_no_similars_and_uses_the_value_as_both_fields() { + // Ledger: a `::text` anchor short-circuits `find_similar` (no + // subtree to search) and always reports one default item built from + // the extracted string itself. Oracle-checked directly against + // `core.op_find_similar({"html": ..., "anchor": "p::text"})`. + let out = op(&json!({"html": "<p>hi</p>", "anchor": "p::text"})).unwrap(); + assert_eq!( + out, + json!({"count": 1, "items": [{"text": "hi", "html": "hi"}]}) + ); + } + + #[test] + fn missing_attr_anchor_is_no_match_not_a_text_result() { + // `p::attr(class)` on a `<p>` with no `class`: css_query yields no + // result at all (nothing to extract), so this is the ordinary + // anchor-missing path, not the text-node short-circuit. + let out = op(&json!({"html": "<p>hi</p>", "anchor": "p::attr(class)"})).unwrap(); + assert_eq!(out, json!({"count": 0, "items": []})); + } +} diff --git a/browser/src/scrapling/ops/mod.rs b/browser/src/scrapling/ops/mod.rs new file mode 100644 index 000000000..00fe1c3b5 --- /dev/null +++ b/browser/src/scrapling/ops/mod.rs @@ -0,0 +1,16 @@ +//! One module per `browser::*` parse op, plus `common` for the +//! response-shaping helpers they share. Each op is a +//! `fn(&Value) -> Result<Value, String>` named `op`; `super::op_for` maps a +//! function id to one of them. + +pub mod common; +pub mod css_fn; +pub mod describe; +pub mod extract; +pub mod find; +pub mod find_by_regex; +pub mod find_by_text; +pub mod find_similar; +pub mod regex_fn; +pub mod to_markdown; +pub mod xpath_fn; diff --git a/browser/src/scrapling/ops/regex_fn.rs b/browser/src/scrapling/ops/regex_fn.rs new file mode 100644 index 000000000..ba028341c --- /dev/null +++ b/browser/src/scrapling/ops/regex_fn.rs @@ -0,0 +1,54 @@ +//! scrapling::regex — regex over the visible text of the document. + +use serde_json::{json, Value}; + +use crate::scrapling::require_str; +use crate::scrapling::{dom, text}; + +pub fn op(payload: &Value) -> Result<Value, String> { + let html = require_str(payload, "html")?; + let pattern = require_str(payload, "pattern")?; + let doc = dom::parse(html); + let all_text = dom::get_all_text(doc.root(), "\n", false, &["script", "style"], true); + let re = text::compile(pattern, false)?; + if payload + .get("first") + .and_then(Value::as_bool) + .unwrap_or(false) + { + Ok(json!({ "result": re.find_first(&all_text) })) + } else { + Ok(json!({ "result": re.findall(&all_text) })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const HTML: &str = r#"<html><body><p>price 42 usd then 99</p></body></html>"#; + + #[test] + fn all_matches_by_default() { + let out = op(&json!({"html": HTML, "pattern": r"\d+"})).unwrap(); + assert_eq!(out, json!({"result": ["42", "99"]})); + } + + #[test] + fn first_returns_scalar_or_null() { + assert_eq!( + op(&json!({"html": HTML, "pattern": r"price (\d+)", "first": true})).unwrap(), + json!({"result": "42"}) + ); + assert_eq!( + op(&json!({"html": HTML, "pattern": "zzz", "first": true})).unwrap(), + json!({"result": null}) + ); + } + + #[test] + fn missing_html_or_pattern_is_an_error() { + assert!(op(&json!({"pattern": "x"})).is_err()); + assert!(op(&json!({"html": HTML})).is_err()); + } +} diff --git a/browser/src/scrapling/ops/to_markdown.rs b/browser/src/scrapling/ops/to_markdown.rs new file mode 100644 index 000000000..781829702 --- /dev/null +++ b/browser/src/scrapling/ops/to_markdown.rs @@ -0,0 +1,25 @@ +//! scrapling::to-markdown — HTML to markdown/text/html, with an optional +//! main-content sanitizer and CSS scope (core.py `op_to_markdown`). Always +//! operates on the whole document: no `adaptive` field in its schema (see +//! schemas.rs `markdown_request`), so no `common::refuse_adaptive` here. + +use serde_json::{json, Value}; + +use crate::scrapling::require_str; +use crate::scrapling::{dom, markdown}; + +pub fn op(payload: &Value) -> Result<Value, String> { + let html = require_str(payload, "html")?; + let doc = dom::parse(html); + let format = payload + .get("format") + .and_then(Value::as_str) + .unwrap_or("markdown"); + let css_selector = payload.get("css_selector").and_then(Value::as_str); + let main_content_only = payload + .get("main_content_only") + .and_then(Value::as_bool) + .unwrap_or(false); + let content = markdown::render(&doc, format, css_selector, main_content_only)?; + Ok(json!({"format": format, "content": content})) +} diff --git a/browser/src/scrapling/ops/xpath_fn.rs b/browser/src/scrapling/ops/xpath_fn.rs new file mode 100644 index 000000000..0d1d50179 --- /dev/null +++ b/browser/src/scrapling/ops/xpath_fn.rs @@ -0,0 +1,83 @@ +//! scrapling::xpath — one XPath query over HTML; first-or-all; `attr` pulls an +//! attribute else text (core.py `op_query(payload, "xpath")`). + +use serde_json::{json, Value}; + +use crate::scrapling::{adaptive, dom, xpath}; +use crate::scrapling::{ops::common, require_str}; + +pub fn op(payload: &Value) -> Result<Value, String> { + let html = require_str(payload, "html")?; + let q = require_str(payload, "query")?; + let doc = dom::parse(html); + let adaptive_enabled = payload + .get("adaptive") + .and_then(Value::as_bool) + .unwrap_or(false); + let attr = payload + .get("attr") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()); + let results = (if adaptive_enabled { + let identifier = payload + .get("identifier") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or(q); + let auto_save = match payload.get("auto_save") { + None => true, + Some(value) => value.as_bool().unwrap_or(false), + }; + adaptive::xpath_query( + &doc, + None, + q, + payload.get("adaptive_domain").and_then(Value::as_str), + identifier, + auto_save, + ) + } else { + xpath::xpath_query(&doc, None, q) + }) + .map_err(|error| { + if attr.is_some() && error == "'str' object has no attribute 'iter'" { + "'str' object has no attribute 'attrib'".to_string() + } else { + error + } + })?; + if payload + .get("first") + .and_then(Value::as_bool) + .unwrap_or(false) + { + Ok(json!({ "result": results.first().map(|r| common::pull(r, attr, false)) })) + } else { + Ok( + json!({ "result": results.iter().map(|r| common::pull(r, attr, false)).collect::<Vec<_>>() }), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn invalid_selector_reports_the_original_expression() { + let err = op(&json!({"html": "<p>x</p>", "query": "//["})).unwrap_err(); + assert_eq!(err, "Invalid XPath selector: //["); + } + + #[test] + fn scalar_attribute_pull_keeps_the_python_error() { + let err = op(&json!({ + "html": "<p>x</p>", + "query": "string(//p)", + "attr": "href" + })) + .unwrap_err(); + assert_eq!(err, "'str' object has no attribute 'attrib'"); + } +} diff --git a/browser/src/scrapling/page.rs b/browser/src/scrapling/page.rs new file mode 100644 index 000000000..9f823b7d7 --- /dev/null +++ b/browser/src/scrapling/page.rs @@ -0,0 +1,274 @@ +//! The shared fetch response envelope, mirroring `scrapling/src/core.py`'s +//! `serialize_page` (core.py:185-208). +//! +//! Every outbound function — `fetch`, `dynamic-fetch`, `stealthy-fetch`, +//! `session-fetch` and each page of `crawl` — returns this shape, so the +//! post-fetch half (inline extraction, markdown/text rendering, raw HTML) is +//! written once here and reuses the parse modules the worker already has. + +use serde_json::{json, Map, Value}; + +use crate::scrapling::{dom, markdown, ops::extract::apply_selectors}; + +/// What a fetch tier produces, independent of how it fetched. +/// +/// `cookies` is a name -> value map for HTTP. Browser tiers preserve the +/// frozen worker's observable serialization quirk and return `{}`. +#[derive(Debug, Default, Clone)] +pub struct PageData { + pub status: Option<u16>, + pub url: String, + pub headers: Map<String, Value>, + pub cookies: Map<String, Value>, + pub encoding: Option<String>, + /// Outer HTML of the fetched document. + pub html: String, + pub captured_xhr: Vec<Value>, +} + +/// Build the response envelope for one fetched page. +/// +/// Key order follows `serialize_page`'s insertion order, which is NOT the +/// property order its JSON Schema declares: the base five, then `extracted`, +/// then `captured_xhr`, then `format` *before* `content`, then `html`. With +/// `serde_json/preserve_order` on, that order is what goes on the wire. +pub fn serialize_page( + page: &PageData, + payload: &Value, + include_html: bool, +) -> Result<Value, String> { + let mut out = Map::new(); + out.insert( + "status".into(), + page.status.map(|s| json!(s)).unwrap_or(Value::Null), + ); + out.insert("url".into(), json!(page.url)); + out.insert("headers".into(), Value::Object(page.headers.clone())); + out.insert("cookies".into(), Value::Object(page.cookies.clone())); + out.insert( + "encoding".into(), + page.encoding + .as_ref() + .map(|e| json!(e)) + .unwrap_or(Value::Null), + ); + + // Parse at most once, and only when something actually needs the tree. + let selectors = payload + .get("selectors") + .and_then(Value::as_array) + .filter(|s| !s.is_empty()); + let fmt = payload + .get("format") + .and_then(Value::as_str) + .filter(|f| matches!(*f, "markdown" | "text")); + let doc = if selectors.is_some() || fmt.is_some() || include_html { + Some(dom::parse(&page.html)) + } else { + None + }; + + if let Some(specs) = selectors { + let doc = doc.as_ref().expect("parsed when selectors present"); + out.insert("extracted".into(), apply_selectors(doc, doc.root(), specs)?); + } + if !page.captured_xhr.is_empty() { + return Err("Object of type Response is not JSON serializable".to_string()); + } + if let Some(fmt) = fmt { + let doc = doc.as_ref().expect("parsed when format present"); + let main_content_only = payload + .get("main_content_only") + .and_then(Value::as_bool) + .unwrap_or(false); + let css_selector = payload.get("css_selector").and_then(Value::as_str); + out.insert("format".into(), json!(fmt)); + out.insert( + "content".into(), + json!(markdown::render(doc, fmt, css_selector, main_content_only)?), + ); + } + if include_html { + let doc = doc.as_ref().expect("parsed when HTML is included"); + out.insert("html".into(), json!(dom::outer_html(doc.root()))); + } + Ok(Value::Object(out)) +} + +/// `include_html` after the caller-specific default precedence has already +/// been applied. Crawl deliberately passes only its request value. +pub fn include_html(payload: &Value) -> bool { + payload + .get("include_html") + .and_then(Value::as_bool) + .unwrap_or(false) +} + +/// The bulk `urls` form: one entry per URL, in request order, where a failed +/// URL contributes `{url, error}` instead of sinking the whole batch +/// (handlers.py:26-42). +pub fn bulk_results(results: Vec<Result<Value, (String, String)>>) -> Value { + let items: Vec<Value> = results + .into_iter() + .map(|r| match r { + Ok(v) => v, + Err((url, error)) => json!({"url": url, "error": error}), + }) + .collect(); + json!({ "results": items }) +} + +/// Read the request's target(s): either a single `url` or a bulk `urls` list. +/// Mirrors handlers.py:20-22, including its error text. +pub fn targets(payload: &Value) -> Result<(Vec<String>, bool), String> { + if let Some(list) = payload.get("urls").and_then(Value::as_array) { + let urls: Vec<String> = list + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect(); + if !urls.is_empty() { + return Ok((urls, true)); + } + } + match payload.get("url").and_then(Value::as_str) { + Some(u) if !u.is_empty() => Ok((vec![u.to_string()], false)), + _ => Err("provide `url` or `urls`".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn page() -> PageData { + PageData { + status: Some(200), + url: "https://example.com/".into(), + headers: [("content-type".to_string(), json!("text/html"))] + .into_iter() + .collect(), + cookies: [("sid".to_string(), json!("abc"))].into_iter().collect(), + encoding: Some("utf-8".into()), + html: "<html><head></head><body><h1>Hi</h1><a href=\"/x\">go</a></body></html>".into(), + captured_xhr: vec![], + } + } + + #[test] + fn base_envelope_has_exactly_the_five_keys_in_order() { + let out = serialize_page(&page(), &json!({}), false).unwrap(); + let keys: Vec<&str> = out + .as_object() + .unwrap() + .keys() + .map(|s| s.as_str()) + .collect(); + assert_eq!(keys, ["status", "url", "headers", "cookies", "encoding"]); + } + + #[test] + fn selectors_add_extracted() { + let payload = json!({"selectors": [{"name": "title", "css": "h1"}]}); + let out = serialize_page(&page(), &payload, false).unwrap(); + assert_eq!(out["extracted"]["title"], json!("Hi")); + } + + #[test] + fn empty_selector_list_adds_nothing() { + let out = serialize_page(&page(), &json!({"selectors": []}), false).unwrap(); + assert!(out.get("extracted").is_none()); + } + + #[test] + fn captured_browser_responses_preserve_the_public_serialization_error() { + let mut page = page(); + page.captured_xhr.push(json!({"status": 200})); + assert_eq!( + serialize_page(&page, &json!({}), false).unwrap_err(), + "Object of type Response is not JSON serializable" + ); + } + + #[test] + fn format_emits_format_before_content() { + let out = serialize_page(&page(), &json!({"format": "text"}), false).unwrap(); + let keys: Vec<&str> = out + .as_object() + .unwrap() + .keys() + .map(|s| s.as_str()) + .collect(); + let f = keys.iter().position(|k| *k == "format").unwrap(); + let c = keys.iter().position(|k| *k == "content").unwrap(); + assert!( + f < c, + "serialize_page inserts format before content: {keys:?}" + ); + // Verified against the reference implementation: `op_to_markdown` in + // text mode yields "Hi\ngo" for this HTML (the h1 is a block, so it + // ends the line). + assert_eq!(out["content"], json!("Hi\ngo")); + } + + #[test] + fn unsupported_format_is_ignored_not_an_error() { + // core.py only renders for markdown|text; anything else falls through. + let out = serialize_page(&page(), &json!({"format": "html"}), false).unwrap(); + assert!(out.get("content").is_none()); + assert!(out.get("format").is_none()); + } + + #[test] + fn include_html_appends_libxml_serialized_html_last() { + let mut page = page(); + page.html = "<p>Hi</p>".into(); + let out = serialize_page(&page, &json!({}), true).unwrap(); + let keys: Vec<&str> = out + .as_object() + .unwrap() + .keys() + .map(|s| s.as_str()) + .collect(); + assert_eq!(*keys.last().unwrap(), "html"); + assert_eq!(out["html"], json!("<html><body><p>Hi</p></body></html>")); + } + + #[test] + fn null_status_and_encoding_serialize_as_null() { + let mut p = page(); + p.status = None; + p.encoding = None; + let out = serialize_page(&p, &json!({}), false).unwrap(); + assert_eq!(out["status"], Value::Null); + assert_eq!(out["encoding"], Value::Null); + } + + #[test] + fn targets_prefers_urls_then_url_then_errors() { + assert_eq!( + targets(&json!({"urls": ["a", "b"]})).unwrap(), + (vec!["a".to_string(), "b".to_string()], true) + ); + assert_eq!( + targets(&json!({"url": "a"})).unwrap(), + (vec!["a".to_string()], false) + ); + // an empty `urls` falls back to `url` rather than fetching nothing + assert_eq!( + targets(&json!({"urls": [], "url": "a"})).unwrap(), + (vec!["a".to_string()], false) + ); + assert_eq!(targets(&json!({})).unwrap_err(), "provide `url` or `urls`"); + } + + #[test] + fn bulk_results_keeps_order_and_isolates_failures() { + let out = bulk_results(vec![ + Ok(json!({"url": "a", "status": 200})), + Err(("b".into(), "boom".into())), + ]); + assert_eq!(out["results"][0]["status"], json!(200)); + assert_eq!(out["results"][1], json!({"url": "b", "error": "boom"})); + } +} diff --git a/browser/src/scrapling/query.rs b/browser/src/scrapling/query.rs new file mode 100644 index 000000000..5a1880d23 --- /dev/null +++ b/browser/src/scrapling/query.rs @@ -0,0 +1,61 @@ +//! Scrapling CSS queries: cssselect translation evaluated by the shared XPath +//! engine, against the same libxml-compatible tree and context node. + +use crate::scrapling::dom::{Doc, ElementRef}; + +#[derive(Debug, Clone)] +pub enum QueryResult<'a> { + Element(ElementRef<'a>), + Text { + value: String, + parent: ElementRef<'a>, + }, +} + +pub fn css_query<'a>( + doc: &'a Doc, + scope: Option<ElementRef<'a>>, + selector: &str, +) -> Result<Vec<QueryResult<'a>>, String> { + let xpath = cssselect::HtmlTranslator::new() + .css_to_xpath(selector) + .map_err(|error| format!("Invalid CSS selector '{selector}': {error}"))?; + crate::scrapling::xpath::xpath_query(doc, scope, &xpath) + .map_err(|error| format!("Invalid CSS selector '{selector}': {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scrapling::dom; + + fn values(results: &[QueryResult<'_>]) -> Vec<String> { + results + .iter() + .map(|result| match result { + QueryResult::Element(element) => format!("<{}>", element.name()), + QueryResult::Text { value, .. } => value.clone(), + }) + .collect() + } + + #[test] + fn pseudo_elements_and_scopes_use_xpath_semantics() { + let doc = dom::parse("<main><section><a href='/a'>A<b>B</b>C</a></section></main>"); + assert_eq!( + values(&css_query(&doc, None, "a::text").unwrap()), + ["A", "C"] + ); + assert_eq!( + values(&css_query(&doc, None, "a ::text").unwrap()), + ["A", "B", "C"] + ); + assert_eq!( + values(&css_query(&doc, None, "a::attr(href)").unwrap()), + ["/a"] + ); + + let section = doc.first_by_tag("section").unwrap(); + assert!(css_query(&doc, Some(section), "body a").unwrap().is_empty()); + } +} diff --git a/browser/src/scrapling/raw_browser.rs b/browser/src/scrapling/raw_browser.rs new file mode 100644 index 000000000..b61cae18e --- /dev/null +++ b/browser/src/scrapling/raw_browser.rs @@ -0,0 +1,2895 @@ +use std::collections::{HashMap, HashSet}; +use std::hash::Hasher; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use image::codecs::png::{CompressionType, FilterType as PngFilterType, PngEncoder}; +use image::imageops::FilterType; +use image::{GenericImageView, ImageEncoder}; +use serde_json::{json, Map, Value}; + +use crate::config::{SecurityMode, WorkerConfig}; +use crate::scrapling::cdp::{CdpClient, CdpEvent, CdpSession, EventReceiver}; +use crate::scrapling::egress_gate::EgressGate; +use crate::scrapling::page::PageData; +use crate::ssrf::SsrfPolicy; + +const CERTIFIED_CHROME_VERSION: &str = "148.0.7778.96"; +const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"; +const GOOGLE_REFERER: &str = "https://www.google.com/"; +const MAX_TILE_WIDTH: u32 = 1024; +const MAX_TILE_HEIGHT: u32 = 1536; +const MAX_TILES: usize = 6; + +const DEFAULT_ARGS: &[&str] = &[ + "--no-pings", + "--no-first-run", + "--disable-infobars", + "--disable-breakpad", + "--no-service-autorun", + "--homepage=about:blank", + "--password-store=basic", + "--disable-hang-monitor", + "--no-default-browser-check", + "--disable-session-crashed-bubble", + "--disable-search-engine-choice-screen", +]; + +const PLAYWRIGHT_ARGS: &[&str] = &[ + "--disable-field-trial-config", + "--disable-background-networking", + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-back-forward-cache", + "--disable-breakpad", + "--disable-client-side-phishing-detection", + "--disable-component-extensions-with-background-pages", + "--no-default-browser-check", + "--disable-dev-shm-usage", + "--disable-edgeupdater", + "--disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Translate,AutoDeElevate,RenderDocument,OptimizationHints,msForceBrowserSignIn,msEdgeUpdateLaunchServicesPreferredVersion", + "--enable-features=CDPScreenshotNewSurface", + "--allow-pre-commit-input", + "--disable-hang-monitor", + "--disable-ipc-flooding-protection", + "--disable-prompt-on-repost", + "--disable-renderer-backgrounding", + "--force-color-profile=srgb", + "--metrics-recording-only", + "--no-first-run", + "--password-store=basic", + "--use-mock-keychain", + "--no-service-autorun", + "--export-tagged-pdf", + "--disable-search-engine-choice-screen", + "--unsafely-disable-devtools-self-xss-warnings", + "--edge-skip-compat-layer-relaunch", + "--disable-infobars", + "--disable-sync", + "--enable-unsafe-swiftshader", +]; + +const PATCHRIGHT_ARGS: &[&str] = &[ + "--disable-field-trial-config", + "--disable-background-networking", + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-breakpad", + "--no-default-browser-check", + "--disable-dev-shm-usage", + "--disable-edgeupdater", + "--disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Translate,AutoDeElevate,RenderDocument,OptimizationHints,msForceBrowserSignIn,msEdgeUpdateLaunchServicesPreferredVersion", + "--enable-features=CDPScreenshotNewSurface", + "--disable-hang-monitor", + "--disable-prompt-on-repost", + "--disable-renderer-backgrounding", + "--force-color-profile=srgb", + "--no-first-run", + "--password-store=basic", + "--use-mock-keychain", + "--no-service-autorun", + "--export-tagged-pdf", + "--disable-search-engine-choice-screen", + "--edge-skip-compat-layer-relaunch", + "--disable-infobars", + "--disable-search-engine-choice-screen", + "--disable-sync", + "--disable-blink-features=AutomationControlled", +]; + +const STEALTH_ARGS: &[&str] = &[ + "--test-type", + "--lang=en-US", + "--mute-audio", + "--disable-sync", + "--hide-scrollbars", + "--disable-logging", + "--start-maximized", + "--enable-async-dns", + "--accept-lang=en-US", + "--use-mock-keychain", + "--disable-translate", + "--disable-voice-input", + "--window-position=0,0", + "--disable-wake-on-wifi", + "--ignore-gpu-blocklist", + "--enable-tcp-fast-open", + "--enable-web-bluetooth", + "--disable-cloud-import", + "--disable-print-preview", + "--disable-dev-shm-usage", + "--metrics-recording-only", + "--disable-crash-reporter", + "--disable-partial-raster", + "--disable-gesture-typing", + "--disable-checker-imaging", + "--disable-prompt-on-repost", + "--force-color-profile=srgb", + "--font-render-hinting=none", + "--aggressive-cache-discard", + "--disable-cookie-encryption", + "--disable-domain-reliability", + "--disable-threaded-animation", + "--disable-threaded-scrolling", + "--enable-simple-cache-backend", + "--disable-background-networking", + "--enable-surface-synchronization", + "--disable-image-animation-resync", + "--disable-renderer-backgrounding", + "--disable-ipc-flooding-protection", + "--prerender-from-omnibox=disabled", + "--safebrowsing-disable-auto-update", + "--disable-offer-upload-credit-cards", + "--disable-background-timer-throttling", + "--disable-new-content-rendering-timeout", + "--run-all-compositor-stages-before-draw", + "--disable-client-side-phishing-detection", + "--disable-backgrounding-occluded-windows", + "--disable-layer-tree-host-memory-pressure", + "--autoplay-policy=user-gesture-required", + "--disable-offer-store-unmasked-wallet-cards", + "--disable-blink-features=AutomationControlled", + "--disable-component-extensions-with-background-pages", + "--enable-features=NetworkService,NetworkServiceInProcess,TrustTokens,TrustTokensAlwaysAllowIssuance", + "--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4", + "--disable-features=AudioServiceOutOfProcess,TranslateUI,BlinkGenPropertyTrees", +]; + +// CPython 3.12 set order with PYTHONHASHSEED=0, retained as an independent +// frozen transcript assertion for the default stealth launch. +#[cfg(test)] +const STEALTH_DEFAULT_ARGS: &[&str] = &[ + "--homepage=about:blank", + "--disable-threaded-animation", + "--disable-offer-store-unmasked-wallet-cards", + "--disable-component-extensions-with-background-pages", + "--enable-async-dns", + "--disable-new-content-rendering-timeout", + "--disable-background-networking", + "--disable-sync", + "--no-service-autorun", + "--disable-checker-imaging", + "--enable-tcp-fast-open", + "--enable-surface-synchronization", + "--disable-offer-upload-credit-cards", + "--run-all-compositor-stages-before-draw", + "--autoplay-policy=user-gesture-required", + "--enable-features=NetworkService,NetworkServiceInProcess,TrustTokens,TrustTokensAlwaysAllowIssuance", + "--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4", + "--accept-lang=en-US", + "--use-mock-keychain", + "--disable-ipc-flooding-protection", + "--no-first-run", + "--disable-threaded-scrolling", + "--safebrowsing-disable-auto-update", + "--disable-infobars", + "--enable-web-bluetooth", + "--disable-domain-reliability", + "--disable-client-side-phishing-detection", + "--hide-scrollbars", + "--enable-simple-cache-backend", + "--disable-features=AudioServiceOutOfProcess,TranslateUI,BlinkGenPropertyTrees", + "--prerender-from-omnibox=disabled", + "--test-type", + "--disable-dev-shm-usage", + "--metrics-recording-only", + "--disable-session-crashed-bubble", + "--mute-audio", + "--aggressive-cache-discard", + "--lang=en-US", + "--disable-voice-input", + "--disable-blink-features=AutomationControlled", + "--disable-breakpad", + "--disable-cookie-encryption", + "--disable-background-timer-throttling", + "--disable-gesture-typing", + "--window-position=0,0", + "--disable-hang-monitor", + "--disable-crash-reporter", + "--disable-logging", + "--disable-image-animation-resync", + "--force-color-profile=srgb", + "--disable-layer-tree-host-memory-pressure", + "--ignore-gpu-blocklist", + "--disable-renderer-backgrounding", + "--disable-print-preview", + "--start-maximized", + "--disable-wake-on-wifi", + "--disable-partial-raster", + "--disable-prompt-on-repost", + "--no-default-browser-check", + "--password-store=basic", + "--no-pings", + "--disable-backgrounding-occluded-windows", + "--disable-translate", + "--disable-cloud-import", + "--disable-search-engine-choice-screen", + "--font-render-hinting=none", +]; + +const BLOCKED_RESOURCE_TYPES: &[&str] = &[ + "Font", + "Image", + "Media", + "TextTrack", + "WebSocket", + "Stylesheet", +]; + +static AD_DOMAINS: OnceLock<HashSet<&'static str>> = OnceLock::new(); +type ProxyConfig = (Option<String>, Option<(String, String)>); + +#[derive(Clone, Debug)] +pub struct RawBrowserOptions { + pub headless: bool, + pub network_idle: bool, + pub load_dom: bool, + pub timeout: Duration, + pub wait: Duration, + pub wait_selector: Option<String>, + pub wait_selector_state: String, + pub disable_resources: bool, + pub block_ads: bool, + pub blocked_domains: Vec<String>, + pub proxy: Option<String>, + pub proxy_auth: Option<(String, String)>, + pub useragent: Option<String>, + pub cookies: Vec<Value>, + pub extra_headers: Map<String, Value>, + pub google_search: bool, + pub capture_xhr: Option<String>, + pub locale: Option<String>, + pub timezone_id: Option<String>, + pub dns_over_https: bool, + pub extra_flags: Vec<String>, + pub retries: usize, + pub retry_delay: Duration, + pub cdp_url: Option<String>, + pub real_chrome: bool, + pub block_webrtc: bool, + pub hide_canvas: bool, + pub allow_webgl: bool, + pub solve_cloudflare: bool, +} + +/// Upper clamp for `retry_delay` (seconds). `Duration::from_secs_f64` PANICS +/// on values it cannot represent, and the schema declares a bare number, so +/// an unclamped `{"retry_delay": 1e300}` would panic inside the handler and +/// hang the caller (the HTTP tier clamps for exactly this reason). +const MAX_RETRY_DELAY_SECS: f64 = 60.0; + +impl RawBrowserOptions { + pub fn from_payload(payload: &Value) -> Result<Self, String> { + let mut timeout = float_field(payload, "timeout", 30_000.0)?; + let wait = float_field(payload, "wait", 0.0)?; + let retry_delay = float_field(payload, "retry_delay", 1.0)?.min(MAX_RETRY_DELAY_SECS); + let retries = bounded_int_field(payload, "retries", 3, 1, 10)?; + let _max_pages = bounded_int_field(payload, "max_pages", 1, 1, 50)?; + let solve_cloudflare = bool_field(payload, "solve_cloudflare", false)?; + if solve_cloudflare { + timeout = timeout.max(60_000.0); + } + let wait_selector_state = string_field(payload, "wait_selector_state", "attached")?; + if !matches!( + wait_selector_state.as_str(), + "attached" | "detached" | "visible" | "hidden" + ) { + return Err(format!( + "Invalid argument type: Invalid enum value '{wait_selector_state}' - at `$.wait_selector_state`" + )); + } + let (proxy, proxy_auth) = proxy_field(payload)?; + Ok(Self { + headless: bool_field(payload, "headless", true)?, + network_idle: bool_field(payload, "network_idle", false)?, + load_dom: bool_field(payload, "load_dom", true)?, + timeout: Duration::from_millis(timeout as u64), + wait: Duration::from_millis(wait as u64), + wait_selector: optional_string_field(payload, "wait_selector")?, + wait_selector_state, + disable_resources: bool_field(payload, "disable_resources", false)?, + block_ads: bool_field(payload, "block_ads", false)?, + blocked_domains: string_list_field(payload, "blocked_domains")?, + proxy, + proxy_auth, + useragent: optional_string_field(payload, "useragent")? + .filter(|value| !value.is_empty()), + cookies: cookies(payload.get("cookies"))?, + extra_headers: string_map_field(payload, "extra_headers")?, + google_search: bool_field(payload, "google_search", true)?, + capture_xhr: optional_string_field(payload, "capture_xhr")? + .filter(|value| !value.is_empty()), + locale: optional_string_field(payload, "locale")?.filter(|value| !value.is_empty()), + timezone_id: optional_string_field(payload, "timezone_id")? + .filter(|value| !value.is_empty()), + dns_over_https: bool_field(payload, "dns_over_https", false)?, + extra_flags: string_list_field(payload, "extra_flags")?, + retries: retries as usize, + retry_delay: Duration::from_secs_f64(retry_delay), + cdp_url: cdp_url(payload)?, + real_chrome: bool_field(payload, "real_chrome", false)?, + block_webrtc: bool_field(payload, "block_webrtc", false)?, + hide_canvas: bool_field(payload, "hide_canvas", false)?, + allow_webgl: bool_field(payload, "allow_webgl", true)?, + solve_cloudflare, + }) + } + + /// Clamp `timeout` and `wait` to the configured ceiling. The deleted + /// python-shaped tier clamped to `max_timeout_ms` and the interactive + /// tier still does; without this a caller-supplied `wait` holds a + /// Chromium page (or wedges a session actor) for an arbitrary time. + pub fn clamp_durations(&mut self, max_timeout_ms: u64) { + let cap = Duration::from_millis(max_timeout_ms.max(1)); + self.timeout = self.timeout.min(cap); + self.wait = self.wait.min(cap); + } + + pub fn validate_policy(&self, mode: SecurityMode) -> Result<(), String> { + if mode == SecurityMode::Compat { + return Ok(()); + } + // Keep in sync with `sessions::uses_compat_only_options`, or an + // option refused here slips through session-open and only errors at + // first fetch (and vice versa). + let mut refused = Vec::new(); + if self.proxy.is_some() { + refused.push("proxy"); + } + if self.cdp_url.is_some() { + refused.push("cdp_url"); + } + if self.dns_over_https { + refused.push("dns_over_https"); + } + if !self.extra_flags.is_empty() { + refused.push("extra_flags"); + } + // Refused rather than silently discarded: safe mode never honors it, + // and an ignored option is a lie to the caller. + if self.real_chrome { + refused.push("real_chrome"); + } + if !refused.is_empty() { + return Err(format!( + "{} require browser.scrapling.security_mode=compat", + refused.join(", ") + )); + } + Ok(()) + } +} + +fn present<'a>(payload: &'a Value, key: &str) -> Option<&'a Value> { + payload.get(key).filter(|value| !value.is_null()) +} + +fn python_type(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "bool", + Value::Number(number) if number.is_i64() || number.is_u64() => "int", + Value::Number(_) => "float", + Value::String(_) => "str", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +fn type_error(expected: &str, value: &Value, path: &str) -> String { + format!( + "Invalid argument type: Expected `{expected}`, got `{}` - at `${path}`", + python_type(value) + ) +} + +fn bool_field(payload: &Value, key: &str, default: bool) -> Result<bool, String> { + let Some(value) = present(payload, key) else { + return Ok(default); + }; + if let Some(value) = value.as_bool() { + return Ok(value); + } + if value + .as_f64() + .is_some_and(|value| value == if default { 1.0 } else { 0.0 }) + { + return Ok(default); + } + Err(type_error("bool", value, &format!(".{key}"))) +} + +fn float_field(payload: &Value, key: &str, default: f64) -> Result<f64, String> { + let Some(value) = present(payload, key) else { + return Ok(default); + }; + if let Some(number) = value.as_f64() { + if number < 0.0 { + return Err(format!( + "Invalid argument type: Expected `float` >= 0.0 - at `$.{key}`" + )); + } + return Ok(number); + } + if value + .as_bool() + .is_some_and(|value| (if value { 1.0 } else { 0.0 }) == default) + { + return Ok(default); + } + Err(type_error("float", value, &format!(".{key}"))) +} + +fn bounded_int_field( + payload: &Value, + key: &str, + default: i64, + minimum: i64, + maximum: i64, +) -> Result<i64, String> { + let Some(value) = present(payload, key) else { + return Ok(default); + }; + let number = if let Some(number) = value.as_i64() { + number + } else if let Some(number) = value.as_u64() { + if number > maximum as u64 { + return Err(format!( + "Invalid argument type: Expected `int` <= {maximum} - at `$.{key}`" + )); + } + number as i64 + } else if value + .as_f64() + .is_some_and(|number| number == default as f64) + || value + .as_bool() + .is_some_and(|number| i64::from(number) == default) + { + default + } else { + return Err(type_error("int", value, &format!(".{key}"))); + }; + if number < minimum { + return Err(format!( + "Invalid argument type: Expected `int` >= {minimum} - at `$.{key}`" + )); + } + if number > maximum { + return Err(format!( + "Invalid argument type: Expected `int` <= {maximum} - at `$.{key}`" + )); + } + Ok(number) +} + +fn string_field(payload: &Value, key: &str, default: &str) -> Result<String, String> { + let Some(value) = present(payload, key) else { + return Ok(default.to_string()); + }; + value + .as_str() + .map(str::to_string) + .ok_or_else(|| type_error("str", value, &format!(".{key}"))) +} + +fn optional_string_field(payload: &Value, key: &str) -> Result<Option<String>, String> { + let Some(value) = present(payload, key) else { + return Ok(None); + }; + value + .as_str() + .map(|value| Some(value.to_string())) + .ok_or_else(|| type_error("str | null", value, &format!(".{key}"))) +} + +fn string_list_field(payload: &Value, key: &str) -> Result<Vec<String>, String> { + let Some(value) = present(payload, key) else { + return Ok(Vec::new()); + }; + let values = value + .as_array() + .ok_or_else(|| type_error("array | null", value, &format!(".{key}")))?; + values + .iter() + .enumerate() + .map(|(index, value)| { + value + .as_str() + .map(str::to_string) + .ok_or_else(|| type_error("str", value, &format!(".{key}[{index}]"))) + }) + .collect() +} + +fn string_map_field(payload: &Value, key: &str) -> Result<Map<String, Value>, String> { + let Some(value) = present(payload, key) else { + return Ok(Map::new()); + }; + let values = value + .as_object() + .ok_or_else(|| type_error("object | null", value, &format!(".{key}")))?; + for value in values.values() { + if !value.is_string() { + return Err(type_error("str", value, &format!(".{key}[...]"))); + } + } + Ok(values.clone()) +} + +fn cdp_url(payload: &Value) -> Result<Option<String>, String> { + let Some(url) = optional_string_field(payload, "cdp_url")? else { + return Ok(None); + }; + if url.is_empty() { + return Ok(None); + } + let Some(authority) = url + .strip_prefix("ws://") + .or_else(|| url.strip_prefix("wss://")) + else { + return Err( + "Invalid argument type: CDP URL must use 'ws://' or 'wss://' scheme".to_string(), + ); + }; + if authority + .split(['/', '?', '#']) + .next() + .unwrap_or_default() + .is_empty() + { + return Err("Invalid argument type: Invalid hostname for the CDP URL".to_string()); + } + Ok(Some(url)) +} + +fn proxy_field(payload: &Value) -> Result<ProxyConfig, String> { + let Some(value) = present(payload, "proxy") else { + return Ok((None, None)); + }; + if let Some(proxy) = value.as_str() { + if proxy.is_empty() { + return Ok((None, None)); + } + let parsed = url::Url::parse(proxy) + .map_err(|_| "Invalid argument type: Invalid proxy string!".to_string())?; + if !matches!(parsed.scheme(), "http" | "https" | "socks4" | "socks5") + || parsed.host_str().is_none() + { + return Err("Invalid argument type: Invalid proxy string!".to_string()); + } + let mut server = format!( + "{}://{}", + parsed.scheme(), + parsed.host_str().unwrap_or_default() + ); + if let Some(port) = parsed.port() { + server.push_str(&format!(":{port}")); + } + let username = parsed.username().to_string(); + let password = parsed.password().unwrap_or_default().to_string(); + let auth = (!username.is_empty() || !password.is_empty()).then_some((username, password)); + return Ok((Some(server), auth)); + } + if let Some(values) = value.as_object() { + for value in values.values() { + if !value.is_string() { + return Err(type_error("str", value, ".proxy[...]")); + } + } + let server = values.get("server").and_then(Value::as_str).ok_or_else(|| { + "Invalid argument type: Invalid proxy dictionary: Object missing required field `server`" + .to_string() + })?; + let username = values + .get("username") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let password = values + .get("password") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let auth = (!username.is_empty() || !password.is_empty()).then_some((username, password)); + return Ok((Some(server.to_string()), auth)); + } + if value.as_array().is_some_and(Vec::is_empty) { + return Ok((None, None)); + } + Err(type_error("str | object | array | null", value, ".proxy")) +} + +pub struct RawBrowser { + client: CdpClient, + context_id: Option<String>, + persistent: bool, + remote: bool, + closed: AtomicBool, + child_router: tokio::task::JoinHandle<()>, + _profile: Option<TempProfile>, + _gate: Option<EgressGate>, +} + +impl RawBrowser { + pub async fn start( + config: &WorkerConfig, + options: &RawBrowserOptions, + stealth: bool, + persistent: bool, + ) -> Result<Self, String> { + crate::scrapling::browserforge::initialize_browser_defaults()?; + options.validate_policy(config.scrapling.security_mode)?; + let gate = if config.scrapling.security_mode == SecurityMode::Safe { + Some( + EgressGate::start(SsrfPolicy { + allow_loopback: config.scrapling.allow_loopback, + }) + .await?, + ) + } else { + None + }; + + let (client, profile, remote) = if let Some(url) = &options.cdp_url { + ( + CdpClient::connect_websocket_url(url) + .await + .map_err(|e| e.to_string())?, + None, + true, + ) + } else { + let executable = chromium_executable(config, options.real_chrome)?; + // The exact-version pin exists for the compat fingerprint story + // (its error message says so); safe mode must accept whatever + // Chromium the system has or the default config is dead on every + // stock Linux install. + if config.scrapling.security_mode == SecurityMode::Compat + && cfg!(all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") + )) + { + certify_chromium(&executable)?; + } + let profile = TempProfile::new()?; + let mut command = + launch_command(&executable, &profile.path, options, stealth, gate.as_ref()); + let client = CdpClient::launch_pipe(&mut command).map_err(|e| e.to_string())?; + (client, Some(profile), false) + }; + + initialize_root(&client, persistent && !remote).await?; + let child_router = tokio::spawn(route_child_targets(client.clone(), stealth)); + let context_id = if remote { + Some(create_browser_context(&client, options.proxy.as_deref()).await?) + } else { + None + }; + Ok(Self { + client, + context_id, + persistent, + remote, + closed: AtomicBool::new(false), + child_router, + _profile: profile, + _gate: gate, + }) + } + + pub async fn fetch( + &self, + url: &str, + options: &RawBrowserOptions, + stealth: bool, + ) -> Result<PageData, String> { + self.fetch_inner(url, options, stealth, false).await + } + + pub async fn fetch_session( + &self, + url: &str, + options: &RawBrowserOptions, + stealth: bool, + proxy_override: bool, + ) -> Result<PageData, String> { + self.fetch_inner(url, options, stealth, proxy_override) + .await + } + + async fn fetch_inner( + &self, + url: &str, + options: &RawBrowserOptions, + stealth: bool, + proxy_override: bool, + ) -> Result<PageData, String> { + let mut last = None; + for attempt in 0..options.retries { + let temporary_context = if proxy_override && options.proxy.is_some() { + if !self.remote { + return Err("Browser not initialized for proxy rotation mode".to_string()); + } + Some(create_browser_context(&self.client, options.proxy.as_deref()).await?) + } else { + None + }; + let context = temporary_context.as_deref().or(self.context_id.as_deref()); + let result = self.fetch_once(url, options, stealth, context).await; + if let Some(context) = temporary_context { + dispose_browser_context(&self.client, &context).await; + } + match result { + Ok(page) => return Ok(page), + Err(error) => last = Some(error), + } + if attempt + 1 < options.retries { + tokio::time::sleep(options.retry_delay).await; + } + } + Err(last.unwrap_or_else(|| "Request failed".to_string())) + } + + async fn fetch_once( + &self, + url: &str, + options: &RawBrowserOptions, + stealth: bool, + context_id: Option<&str>, + ) -> Result<PageData, String> { + let mut page = RawPage::open(&self.client, context_id, options, stealth).await?; + let result = page.navigate(url, options).await; + page.close().await; + result + } + + pub async fn screenshot( + &self, + url: &str, + options: &RawBrowserOptions, + stealth: bool, + full_page: bool, + format: &str, + ) -> Result<(Vec<Value>, String, String), String> { + let mut page = + RawPage::open(&self.client, self.context_id.as_deref(), options, stealth).await?; + // Close the page on every path — an early `?` here would leak the tab. + let shot = async { + let final_url = page.navigate(url, options).await?.url; + let (image, mime) = page.screenshot(full_page, format).await?; + Ok::<_, String>((final_url, image, mime)) + } + .await; + page.close().await; + let (final_url, image, mime) = shot?; + let (tiles, width, height, truncated) = tile_screenshot(&image, format)?; + let kb = + python_round(tiles.iter().map(Vec::len).sum::<usize>() as f64 / 1024.0).max(1) as usize; + let mut content = tiles + .into_iter() + .map(|tile| json!({"type": "image", "mime": mime, "data": STANDARD.encode(tile)})) + .collect::<Vec<_>>(); + let mut caption = format!( + "screenshot of {final_url} — {width}x{height}px, {} tile(s), {kb} KB", + content.len() + ); + if truncated { + caption.push_str(" (truncated: page taller than 6 tiles)"); + } + content.push(json!({"type": "text", "text": caption})); + Ok((content, mime, final_url)) + } + + pub async fn shutdown(&self) { + if self.closed.swap(true, Ordering::AcqRel) { + return; + } + self.child_router.abort(); + if let Some(context) = &self.context_id { + dispose_browser_context(&self.client, context).await; + } + if self.persistent && !self.remote { + if let Ok(command) = self.client.send("Browser.close", json!({})) { + let _ = command.await; + } + } + let _ = self.client.close(); + } +} + +impl Drop for RawBrowser { + fn drop(&mut self) { + if self.closed.swap(true, Ordering::AcqRel) { + return; + } + self.child_router.abort(); + if let Some(context_id) = self.context_id.take() { + if let Ok(command) = self.client.send( + "Target.disposeBrowserContext", + json!({"browserContextId": context_id}), + ) { + drop(command); + } + } + if self.persistent && !self.remote { + if let Ok(command) = self.client.send("Browser.close", json!({})) { + drop(command); + } + } + let _ = self.client.close(); + } +} + +async fn create_browser_context(client: &CdpClient, proxy: Option<&str>) -> Result<String, String> { + let mut params = Map::new(); + params.insert("disposeOnDetach".to_string(), json!(true)); + if let Some(proxy) = proxy { + params.insert("proxyServer".to_string(), json!(proxy)); + params.insert("proxyBypassList".to_string(), json!("<-loopback>")); + } + client + .send("Target.createBrowserContext", Value::Object(params)) + .map_err(|error| error.to_string())? + .await + .map_err(|error| error.to_string())? + .get("browserContextId") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| "Target.createBrowserContext returned no browserContextId".to_string()) +} + +async fn dispose_browser_context(client: &CdpClient, context: &str) { + if let Ok(command) = client.send( + "Target.disposeBrowserContext", + json!({"browserContextId": context}), + ) { + let _ = command.await; + } +} + +async fn route_child_targets(client: CdpClient, stealth: bool) { + // Auto-attach happens on the PAGE session, so child targets announce + // themselves tagged with the page's sessionId — a root-scoped receiver + // never sees them and the child stays frozen on waitForDebuggerOnStart. + let mut events = client.subscribe_any(); + while let Ok(event) = events.recv().await { + if event.method != "Target.attachedToTarget" { + continue; + } + let Some(session_id) = event.params.get("sessionId").and_then(Value::as_str) else { + continue; + }; + let target_type = event + .params + .pointer("/targetInfo/type") + .and_then(Value::as_str) + .unwrap_or_default(); + if target_type == "page" { + continue; + } + let session = client.session(session_id); + let runtime = if stealth { + session.send( + "Runtime.evaluate", + json!({ + "expression": "globalThis", + "serializationOptions": {"serialization": "idOnly"}, + "returnByValue": false + }), + ) + } else { + session.send("Runtime.enable", json!({})) + }; + if let Ok(runtime) = runtime { + let _ = runtime.await; + } + if let Ok(resume) = session.send("Runtime.runIfWaitingForDebugger", json!({})) { + let _ = resume.await; + } + } +} + +struct RawPage { + client: CdpClient, + session: CdpSession, + target_id: String, + events: EventReceiver, +} + +impl RawPage { + async fn open( + client: &CdpClient, + context_id: Option<&str>, + options: &RawBrowserOptions, + stealth: bool, + ) -> Result<Self, String> { + let mut root_events = client.subscribe(); + let mut params = json!({"url": "about:blank", "newWindow": false}); + if let Some(context) = context_id { + params["browserContextId"] = json!(context); + } + let target_id = client + .send("Target.createTarget", params) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())? + .get("targetId") + .and_then(Value::as_str) + .ok_or("Target.createTarget returned no targetId")? + .to_string(); + // From here on the created target must not leak: every error path + // closes it, or failed opens pile up tabs in the pooled browser. + let attach = async { + let deadline = Instant::now() + options.timeout; + let session_id = loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(format!("timed out attaching to browser target {target_id}")); + } + let event = tokio::time::timeout(remaining, root_events.recv()) + .await + .map_err(|_| format!("timed out attaching to browser target {target_id}"))? + .map_err(|e| e.to_string())?; + if event.method == "Target.attachedToTarget" + && event + .params + .pointer("/targetInfo/targetId") + .and_then(Value::as_str) + == Some(target_id.as_str()) + { + break event + .params + .get("sessionId") + .and_then(Value::as_str) + .ok_or("Target.attachedToTarget returned no sessionId")? + .to_string(); + } + }; + let session = client.session(session_id); + let events = session.subscribe(); + initialize_page(&session, options, stealth).await?; + Ok((session, events)) + } + .await; + match attach { + Ok((session, events)) => Ok(Self { + client: client.clone(), + session, + target_id, + events, + }), + Err(error) => { + if let Ok(close) = client.send("Target.closeTarget", json!({"targetId": target_id})) + { + let _ = close.await; + } + Err(error) + } + } + } + + async fn navigate( + &mut self, + url: &str, + options: &RawBrowserOptions, + ) -> Result<PageData, String> { + let navigate = self + .session + .send( + "Page.navigate", + json!({ + "url": url, + "referrer": referer(options), + "transitionType": "typed" + }), + ) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + if let Some(error) = navigate.get("errorText").and_then(Value::as_str) { + return Err(error.to_string()); + } + let main_frame = navigate + .get("frameId") + .and_then(Value::as_str) + .map(str::to_string); + + let deadline = Instant::now() + options.timeout; + let mut loaded = false; + let mut dom_loaded = !options.load_dom; + let mut inflight = HashSet::new(); + let mut quiet_since = Instant::now(); + let mut responses = HashMap::<String, ResponseInfo>::new(); + let mut document_request = None; + let mut xhr_ids = Vec::new(); + loop { + if loaded + && dom_loaded + && (!options.network_idle + || (inflight.is_empty() && quiet_since.elapsed() >= Duration::from_millis(500))) + { + break; + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + let event = match tokio::time::timeout( + remaining.min(Duration::from_millis(100)), + self.events.recv(), + ) + .await + { + Ok(Ok(event)) => event, + Ok(Err(error)) => return Err(error.to_string()), + Err(_) => continue, + }; + self.track_event( + event, + options, + main_frame.as_deref(), + &mut loaded, + &mut dom_loaded, + &mut inflight, + &mut quiet_since, + &mut responses, + &mut document_request, + &mut xhr_ids, + ) + .await?; + } + + if options.solve_cloudflare { + self.solve_cloudflare( + options, + main_frame.as_deref(), + &mut loaded, + &mut dom_loaded, + &mut inflight, + &mut quiet_since, + &mut responses, + &mut document_request, + &mut xhr_ids, + ) + .await?; + } + if let Some(selector) = &options.wait_selector { + let found = self + .wait_selector(selector, &options.wait_selector_state, options.timeout) + .await; + if found && options.network_idle { + self.wait_for_network_idle( + options.timeout, + options, + main_frame.as_deref(), + &mut loaded, + &mut dom_loaded, + &mut inflight, + &mut quiet_since, + &mut responses, + &mut document_request, + &mut xhr_ids, + ) + .await?; + } + } + if !options.wait.is_zero() { + tokio::time::sleep(options.wait).await; + } + let final_url = self + .evaluate("location.href", true) + .await? + .as_str() + .unwrap_or(url) + .to_string(); + let response = document_request + .as_ref() + .and_then(|request| responses.get(request)) + .cloned(); + let content_type = response + .as_ref() + .and_then(|value| value.headers.get("content-type")) + .and_then(Value::as_str) + .unwrap_or_default(); + let html = if content_type.contains("html") || document_request.is_none() { + self.page_html().await? + } else { + let body = self + .session + .send( + "Network.getResponseBody", + json!({"requestId": document_request.as_deref().unwrap_or_default()}), + ) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + let body = decode_network_body(&body, charset(content_type).as_deref())?; + let document = crate::scrapling::dom::parse(&body); + crate::scrapling::dom::outer_html(document.root()) + }; + let mut captured_xhr = Vec::new(); + for id in xhr_ids { + if let Some(info) = responses.get(&id) { + captured_xhr.push(info.as_value()); + } + } + Ok(PageData { + status: response.as_ref().and_then(|value| value.status), + url: final_url, + headers: response + .as_ref() + .map(|value| value.headers.clone()) + .unwrap_or_default(), + // The frozen wrapper's tuple-of-cookie-dicts serialization quirk is `{}`. + cookies: Map::new(), + encoding: response + .as_ref() + .and_then(|value| value.headers.get("content-type")) + .and_then(Value::as_str) + .and_then(charset) + .or_else(|| Some("utf-8".to_string())), + html, + captured_xhr, + }) + } + + async fn evaluate(&self, expression: &str, return_by_value: bool) -> Result<Value, String> { + let result = self + .session + .send( + "Runtime.evaluate", + json!({ + "expression": expression, + "returnByValue": return_by_value, + "awaitPromise": true, + "userGesture": true + }), + ) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + if let Some(exception) = result.get("exceptionDetails") { + return Err(format!("JavaScript evaluation failed: {exception}")); + } + Ok(result + .get("result") + .and_then(|value| value.get("value")) + .cloned() + .unwrap_or(Value::Null)) + } + + async fn intercept(&self, event: &CdpEvent, options: &RawBrowserOptions) -> Result<(), String> { + let request_id = event + .params + .get("requestId") + .and_then(Value::as_str) + .ok_or("Fetch.requestPaused returned no requestId")?; + let resource = event + .params + .get("resourceType") + .and_then(Value::as_str) + .unwrap_or_default(); + let host = event + .params + .pointer("/request/url") + .and_then(Value::as_str) + .and_then(|url| url::Url::parse(url).ok()) + .and_then(|url| url.host_str().map(str::to_string)); + let blocked = (options.disable_resources && BLOCKED_RESOURCE_TYPES.contains(&resource)) + || host.as_deref().is_some_and(|host| { + options + .blocked_domains + .iter() + .any(|domain| domain_matches(host, domain)) + || (options.block_ads + && ad_domains() + .iter() + .any(|domain| domain_matches(host, domain))) + }); + let (method, params) = if blocked { + ( + "Fetch.failRequest", + json!({"requestId": request_id, "errorReason": "BlockedByClient"}), + ) + } else { + ("Fetch.continueRequest", json!({"requestId": request_id})) + }; + self.session + .send(method, params) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + Ok(()) + } + + async fn authenticate( + &self, + event: &CdpEvent, + options: &RawBrowserOptions, + ) -> Result<(), String> { + let request_id = event + .params + .get("requestId") + .and_then(Value::as_str) + .ok_or("Fetch.authRequired returned no requestId")?; + let is_proxy = event + .params + .pointer("/authChallenge/source") + .and_then(Value::as_str) + == Some("Proxy"); + let response = if let (true, Some((username, password))) = (is_proxy, &options.proxy_auth) { + json!({ + "response": "ProvideCredentials", + "username": username, + "password": password + }) + } else { + json!({"response": "Default"}) + }; + self.session + .send( + "Fetch.continueWithAuth", + json!({"requestId": request_id, "authChallengeResponse": response}), + ) + .map_err(|error| error.to_string())? + .await + .map_err(|error| error.to_string())?; + Ok(()) + } + + async fn page_html(&self) -> Result<String, String> { + Ok(self + .evaluate("document.documentElement.outerHTML", true) + .await? + .as_str() + .unwrap_or_default() + .to_string()) + } + + async fn wait_selector(&self, selector: &str, state: &str, timeout: Duration) -> bool { + let selector = serde_json::to_string(selector).unwrap_or_else(|_| "null".to_string()); + let predicate = match state { + "detached" => format!("!document.querySelector({selector})"), + "visible" => format!("(()=>{{const e=document.querySelector({selector});return !!e&&(e.offsetParent!==null||e.getClientRects().length>0)}})()"), + "hidden" => format!("(()=>{{const e=document.querySelector({selector});return !e||(e.offsetParent===null&&e.getClientRects().length===0)}})()"), + _ => format!("!!document.querySelector({selector})"), + }; + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if self.evaluate(&predicate, true).await == Ok(Value::Bool(true)) { + return true; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + false + } + + #[allow(clippy::too_many_arguments)] + async fn track_event( + &self, + event: CdpEvent, + options: &RawBrowserOptions, + main_frame: Option<&str>, + loaded: &mut bool, + dom_loaded: &mut bool, + inflight: &mut HashSet<String>, + quiet_since: &mut Instant, + responses: &mut HashMap<String, ResponseInfo>, + document_request: &mut Option<String>, + xhr_ids: &mut Vec<String>, + ) -> Result<(), String> { + match event.method.as_str() { + "Fetch.requestPaused" => { + self.intercept(&event, options).await?; + return Ok(()); + } + "Fetch.authRequired" => { + self.authenticate(&event, options).await?; + return Ok(()); + } + _ => {} + } + handle_event( + event, + main_frame, + options.capture_xhr.as_deref(), + loaded, + dom_loaded, + inflight, + quiet_since, + responses, + document_request, + xhr_ids, + ); + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + async fn wait_for_network_idle( + &mut self, + timeout: Duration, + options: &RawBrowserOptions, + main_frame: Option<&str>, + loaded: &mut bool, + dom_loaded: &mut bool, + inflight: &mut HashSet<String>, + quiet_since: &mut Instant, + responses: &mut HashMap<String, ResponseInfo>, + document_request: &mut Option<String>, + xhr_ids: &mut Vec<String>, + ) -> Result<(), String> { + let deadline = Instant::now() + timeout; + loop { + let idle = inflight.is_empty() && quiet_since.elapsed() >= Duration::from_millis(500); + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Ok(()); + } + let wait = if idle { + Duration::from_millis(1) + } else { + remaining.min(Duration::from_millis(100)) + }; + match tokio::time::timeout(wait, self.events.recv()).await { + Ok(Ok(event)) => { + self.track_event( + event, + options, + main_frame, + loaded, + dom_loaded, + inflight, + quiet_since, + responses, + document_request, + xhr_ids, + ) + .await?; + } + Ok(Err(error)) => return Err(error.to_string()), + Err(_) if idle => return Ok(()), + Err(_) => {} + } + } + } + + #[allow(clippy::too_many_arguments)] + async fn wait_for_load_state( + &mut self, + timeout: Duration, + options: &RawBrowserOptions, + main_frame: Option<&str>, + loaded: &mut bool, + dom_loaded: &mut bool, + inflight: &mut HashSet<String>, + quiet_since: &mut Instant, + responses: &mut HashMap<String, ResponseInfo>, + document_request: &mut Option<String>, + xhr_ids: &mut Vec<String>, + ) -> Result<(), String> { + let deadline = Instant::now() + timeout; + loop { + match tokio::time::timeout(Duration::from_millis(1), self.events.recv()).await { + Ok(Ok(event)) => { + self.track_event( + event, + options, + main_frame, + loaded, + dom_loaded, + inflight, + quiet_since, + responses, + document_request, + xhr_ids, + ) + .await?; + continue; + } + Ok(Err(error)) => return Err(error.to_string()), + Err(_) => {} + } + if self + .evaluate("document.readyState === 'complete'", true) + .await? + == Value::Bool(true) + { + return Ok(()); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err("Timeout while waiting for page load state".to_string()); + } + match tokio::time::timeout( + remaining.min(Duration::from_millis(100)), + self.events.recv(), + ) + .await + { + Ok(Ok(event)) => { + self.track_event( + event, + options, + main_frame, + loaded, + dom_loaded, + inflight, + quiet_since, + responses, + document_request, + xhr_ids, + ) + .await?; + } + Ok(Err(error)) => return Err(error.to_string()), + Err(_) => {} + } + } + } + + #[allow(clippy::too_many_arguments)] + async fn solve_cloudflare( + &mut self, + options: &RawBrowserOptions, + main_frame: Option<&str>, + loaded: &mut bool, + dom_loaded: &mut bool, + inflight: &mut HashSet<String>, + quiet_since: &mut Instant, + responses: &mut HashMap<String, ResponseInfo>, + document_request: &mut Option<String>, + xhr_ids: &mut Vec<String>, + ) -> Result<(), String> { + // Every poll loop below waits on Cloudflare resolving; a challenge + // that never clears (a hard block, a broken Turnstile) would otherwise + // spin forever and brick the session slot. One deadline bounds the + // whole solve. + let solve_deadline = Instant::now() + options.timeout; + let expired = || Instant::now() >= solve_deadline; + let mut html; + loop { + if expired() { + return Err("timed out solving the Cloudflare challenge".to_string()); + } + self.wait_for_network_idle( + Duration::from_secs(5), + options, + main_frame, + loaded, + dom_loaded, + inflight, + quiet_since, + responses, + document_request, + xhr_ids, + ) + .await?; + html = self.page_html().await?; + let Some(challenge) = cloudflare_challenge(&html) else { + return Ok(()); + }; + if challenge == "non-interactive" { + while html.contains("<title>Just a moment...</title>") { + if expired() { + return Err("timed out solving the Cloudflare challenge".to_string()); + } + tokio::time::sleep(Duration::from_secs(1)).await; + self.wait_for_load_state( + options.timeout, + options, + main_frame, + loaded, + dom_loaded, + inflight, + quiet_since, + responses, + document_request, + xhr_ids, + ) + .await?; + html = self.page_html().await?; + } + return Ok(()); + } + + let selector = if challenge == "embedded" { + "#cf_turnstile div, #cf-turnstile div, .turnstile>div>div" + } else { + ".main-content p+div>div>div" + }; + if challenge != "embedded" { + while html.contains("Verifying you are human.") { + if expired() { + return Err("timed out solving the Cloudflare challenge".to_string()); + } + tokio::time::sleep(Duration::from_millis(500)).await; + html = self.page_html().await?; + } + } + + let (has_iframe, mut outer_box) = self.challenge_iframe_box().await?; + if has_iframe && challenge != "embedded" { + while outer_box.is_none() { + if expired() { + return Err("timed out solving the Cloudflare challenge".to_string()); + } + tokio::time::sleep(Duration::from_millis(500)).await; + outer_box = self.challenge_iframe_box().await?.1; + } + } + if !has_iframe || outer_box.is_none() { + html = self.page_html().await?; + if !html.contains("<title>Just a moment...</title>") { + return Ok(()); + } + outer_box = self.selector_box(selector).await?; + } + let (x, y) = outer_box + .ok_or_else(|| "TypeError: 'NoneType' object is not subscriptable".to_string())?; + let x = x + f64::from(crate::scrapling::browserforge::randint(26, 28)); + let y = y + f64::from(crate::scrapling::browserforge::randint(25, 27)); + let delay = crate::scrapling::browserforge::randint(100, 200); + self.session + .send( + "Input.dispatchMouseEvent", + json!({"type":"mouseMoved","x":x,"y":y}), + ) + .map_err(|error| error.to_string())? + .await + .map_err(|error| error.to_string())?; + self.session + .send( + "Input.dispatchMouseEvent", + json!({"type":"mousePressed","x":x,"y":y,"button":"left","clickCount":1}), + ) + .map_err(|error| error.to_string())? + .await + .map_err(|error| error.to_string())?; + tokio::time::sleep(Duration::from_millis(u64::from(delay))).await; + self.session + .send( + "Input.dispatchMouseEvent", + json!({"type":"mouseReleased","x":x,"y":y,"button":"left","clickCount":1}), + ) + .map_err(|error| error.to_string())? + .await + .map_err(|error| error.to_string())?; + + self.wait_for_network_idle( + options.timeout, + options, + main_frame, + loaded, + dom_loaded, + inflight, + quiet_since, + responses, + document_request, + xhr_ids, + ) + .await?; + if challenge != "embedded" { + for _ in 0..100 { + html = self.page_html().await?; + if !html.contains("<title>Just a moment...</title>") { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + self.wait_for_load_state( + options.timeout, + options, + main_frame, + loaded, + dom_loaded, + inflight, + quiet_since, + responses, + document_request, + xhr_ids, + ) + .await?; + html = self.page_html().await?; + if !html.contains("<title>Just a moment...</title>") { + return Ok(()); + } + } + } + + async fn challenge_iframe_box(&self) -> Result<(bool, Option<(f64, f64)>), String> { + let tree = self + .session + .send("Page.getFrameTree", json!({})) + .map_err(|error| error.to_string())? + .await + .map_err(|error| error.to_string())?; + let Some(frame_id) = challenge_frame_id(tree.get("frameTree").unwrap_or(&Value::Null)) + else { + return Ok((false, None)); + }; + let owner = self + .session + .send("DOM.getFrameOwner", json!({"frameId": frame_id})) + .map_err(|error| error.to_string())? + .await; + let Ok(owner) = owner else { + return Ok((true, None)); + }; + let Some(backend_node_id) = owner.get("backendNodeId").and_then(Value::as_u64) else { + return Ok((true, None)); + }; + let model = self + .session + .send("DOM.getBoxModel", json!({"backendNodeId": backend_node_id})) + .map_err(|error| error.to_string())? + .await; + let Ok(model) = model else { + return Ok((true, None)); + }; + Ok(( + true, + box_origin(model.pointer("/model/border").unwrap_or(&Value::Null)), + )) + } + + async fn selector_box(&self, selector: &str) -> Result<Option<(f64, f64)>, String> { + let selector = serde_json::to_string(selector).map_err(|error| error.to_string())?; + let point = self + .evaluate( + &format!("(()=>{{const e=[...document.querySelectorAll({selector})].at(-1);if(!e)return null;const r=e.getBoundingClientRect();return{{x:r.x,y:r.y,w:r.width,h:r.height}}}})()"), + true, + ) + .await?; + match ( + point.get("x").and_then(Value::as_f64), + point.get("y").and_then(Value::as_f64), + point.get("w").and_then(Value::as_f64), + point.get("h").and_then(Value::as_f64), + ) { + (Some(x), Some(y), Some(width), Some(height)) if width > 0.0 && height > 0.0 => { + Ok(Some((x, y))) + } + _ => Ok(None), + } + } + + async fn screenshot(&self, full_page: bool, format: &str) -> Result<(Vec<u8>, String), String> { + let (format, mime) = match format { + "png" => ("png", "image/png"), + "jpeg" | "jpg" => ("jpeg", "image/jpeg"), + other => return Err(format!("unsupported format: {other} (use png or jpeg)")), + }; + let mut params = json!({"format": format, "fromSurface": true}); + if full_page { + let metrics = self + .session + .send("Page.getLayoutMetrics", json!({})) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + if let Some(size) = metrics.get("cssContentSize") { + params["captureBeyondViewport"] = json!(true); + params["clip"] = json!({ + "x": size.get("x").and_then(Value::as_f64).unwrap_or(0.0), + "y": size.get("y").and_then(Value::as_f64).unwrap_or(0.0), + "width": size.get("width").and_then(Value::as_f64).unwrap_or(1280.0), + "height": size.get("height").and_then(Value::as_f64).unwrap_or(800.0), + "scale": 1 + }); + } + } + let shot = self + .session + .send("Page.captureScreenshot", params) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + let data = shot + .get("data") + .and_then(Value::as_str) + .ok_or("Page.captureScreenshot returned no data")?; + Ok(( + STANDARD.decode(data).map_err(|e| e.to_string())?, + mime.to_string(), + )) + } + + async fn close(&self) { + if let Ok(command) = self + .client + .send("Target.closeTarget", json!({"targetId": self.target_id})) + { + let _ = command.await; + } + } +} + +#[derive(Clone)] +struct ResponseInfo { + url: String, + status: Option<u16>, + headers: Map<String, Value>, + request_headers: Map<String, Value>, +} + +impl ResponseInfo { + fn as_value(&self) -> Value { + json!({ + "status": self.status, + "url": self.url, + "headers": self.headers, + "cookies": {}, + "encoding": self.headers.get("content-type").and_then(Value::as_str).and_then(charset).unwrap_or_else(|| "utf-8".to_string()), + "content": "", + "history": [], + "request_headers": self.request_headers, + }) + } +} + +#[allow(clippy::too_many_arguments)] +fn handle_event( + event: CdpEvent, + main_frame: Option<&str>, + capture_xhr: Option<&str>, + loaded: &mut bool, + dom_loaded: &mut bool, + inflight: &mut HashSet<String>, + quiet_since: &mut Instant, + responses: &mut HashMap<String, ResponseInfo>, + document_request: &mut Option<String>, + xhr_ids: &mut Vec<String>, +) { + match event.method.as_str() { + "Page.loadEventFired" => *loaded = true, + "Page.domContentEventFired" => *dom_loaded = true, + "Network.requestWillBeSent" => { + if let Some(request_id) = event.params.get("requestId").and_then(Value::as_str) { + inflight.insert(request_id.to_string()); + } + *quiet_since = Instant::now(); + if event.params.get("type").and_then(Value::as_str) == Some("Document") + && event + .params + .get("frameId") + .and_then(Value::as_str) + .is_some_and(|frame| main_frame.is_none_or(|main| main == frame)) + { + *document_request = event + .params + .get("requestId") + .and_then(Value::as_str) + .map(str::to_string); + } + } + "Network.loadingFinished" | "Network.loadingFailed" => { + if let Some(request_id) = event.params.get("requestId").and_then(Value::as_str) { + inflight.remove(request_id); + } + *quiet_since = Instant::now(); + } + "Network.responseReceived" => { + let Some(id) = event.params.get("requestId").and_then(Value::as_str) else { + return; + }; + let response = event.params.get("response").cloned().unwrap_or(Value::Null); + let url = response + .get("url") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let resource_type = event.params.get("type").and_then(Value::as_str); + if matches!(resource_type, Some("XHR" | "Fetch")) + && capture_xhr.is_some_and(|pattern| { + crate::scrapling::text::compile(pattern, false) + .is_ok_and(|pattern| pattern.check_match(&url)) + }) + { + xhr_ids.push(id.to_string()); + } + responses.insert( + id.to_string(), + ResponseInfo { + url, + status: response + .get("status") + .and_then(Value::as_f64) + .map(|status| status as u16), + headers: response + .get("headers") + .and_then(Value::as_object) + .map(playwright_headers) + .unwrap_or_default(), + request_headers: event + .params + .pointer("/response/requestHeaders") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(), + }, + ); + } + _ => {} + } +} + +fn playwright_headers(headers: &Map<String, Value>) -> Map<String, Value> { + headers + .iter() + .map(|(name, value)| { + let name = name.to_ascii_lowercase(); + let value = value + .as_str() + .map(|value| { + if name == "set-cookie" { + value.to_string() + } else { + value.replace('\n', ", ") + } + }) + .map(Value::String) + .unwrap_or_else(|| value.clone()); + (name, value) + }) + .collect() +} + +async fn initialize_root(client: &CdpClient, persistent: bool) -> Result<(), String> { + client + .send("Browser.getVersion", json!({})) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + client + .send( + "Target.setAutoAttach", + json!({ + "autoAttach": true, + "waitForDebuggerOnStart": true, + "flatten": true + }), + ) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + if persistent { + client + .send("Target.getTargetInfo", json!({})) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +async fn initialize_page( + page: &CdpSession, + options: &RawBrowserOptions, + stealth: bool, +) -> Result<(), String> { + for (method, params) in [ + ("Page.enable", json!({})), + ("Page.getFrameTree", json!({})), + ("Log.enable", json!({})), + ("Page.setLifecycleEventsEnabled", json!({"enabled": true})), + ] { + page.send(method, params) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + } + if !stealth { + page.send("Runtime.enable", json!({})) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + } + page.send( + "Page.addScriptToEvaluateOnNewDocument", + json!({"source": "", "worldName": "__playwright_utility_world__"}), + ) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + let (width, height) = if stealth { (1920, 1080) } else { (1280, 720) }; + page.send( + "Emulation.setDeviceMetricsOverride", + json!({ + "width": width, + "height": height, + "deviceScaleFactor": 2, + "mobile": false, + "screenWidth": width, + "screenHeight": height + }), + ) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + page.send( + "Emulation.setEmulatedMedia", + json!({"features": [{"name": "prefers-color-scheme", "value": "dark"}]}), + ) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + page.send("Network.enable", json!({})) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + if options.disable_resources + || options.block_ads + || !options.blocked_domains.is_empty() + || options.proxy_auth.is_some() + { + page.send( + "Fetch.enable", + json!({ + "patterns": [{"urlPattern": "*", "requestStage": "Request"}], + "handleAuthRequests": options.proxy_auth.is_some() + }), + ) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + } + page.send( + "Target.setAutoAttach", + json!({"autoAttach": true, "waitForDebuggerOnStart": true, "flatten": true}), + ) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + let user_agent = options + .useragent + .as_deref() + .or(options.headless.then_some(DEFAULT_USER_AGENT)); + if user_agent.is_some() || options.locale.is_some() { + let mut params = Map::new(); + params.insert( + "userAgent".to_string(), + json!(user_agent.unwrap_or(DEFAULT_USER_AGENT)), + ); + if let Some(locale) = &options.locale { + params.insert("acceptLanguage".to_string(), json!(locale)); + } + page.send("Network.setUserAgentOverride", Value::Object(params)) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + } + if !options.extra_headers.is_empty() { + page.send( + "Network.setExtraHTTPHeaders", + json!({"headers": options.extra_headers}), + ) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + } + if let Some(timezone) = &options.timezone_id { + page.send( + "Emulation.setTimezoneOverride", + json!({"timezoneId": timezone}), + ) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + } + if !options.cookies.is_empty() { + page.send("Network.setCookies", json!({"cookies": options.cookies})) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + } + if let Some(locale) = &options.locale { + page.send("Emulation.setLocaleOverride", json!({"locale": locale})) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + } + page.send("Runtime.runIfWaitingForDebugger", json!({})) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + +fn referer(options: &RawBrowserOptions) -> String { + let has_referer = options + .extra_headers + .keys() + .any(|header| header.eq_ignore_ascii_case("referer")); + if options.google_search && !has_referer { + GOOGLE_REFERER.to_string() + } else { + String::new() + } +} + +fn cookies(value: Option<&Value>) -> Result<Vec<Value>, String> { + let Some(value) = value.filter(|value| !value.is_null()) else { + return Ok(Vec::new()); + }; + let values = value + .as_array() + .ok_or_else(|| type_error("array | null", value, ".cookies"))?; + let mut output = Vec::with_capacity(values.len()); + for (index, value) in values.iter().enumerate() { + let object = value + .as_object() + .ok_or_else(|| type_error("object", value, &format!(".cookies[{index}]")))?; + let mut cookie = Map::new(); + for key in ["name", "value", "url", "domain", "path", "partitionKey"] { + let Some(value) = object.get(key) else { + continue; + }; + if !value.is_null() && !value.is_string() { + return Err(type_error( + if matches!(key, "name" | "value") { + "str" + } else { + "str | null" + }, + value, + &format!(".cookies[{index}].{key}"), + )); + } + cookie.insert(key.to_string(), value.clone()); + } + if let Some(value) = object.get("expires") { + if !value.is_null() && value.as_f64().is_none() { + return Err(type_error( + "float | null", + value, + &format!(".cookies[{index}].expires"), + )); + } + cookie.insert("expires".to_string(), value.clone()); + } + for key in ["httpOnly", "secure"] { + let Some(value) = object.get(key) else { + continue; + }; + if !value.is_null() && !value.is_boolean() { + return Err(type_error( + "bool | null", + value, + &format!(".cookies[{index}].{key}"), + )); + } + cookie.insert(key.to_string(), value.clone()); + } + if let Some(value) = object.get("sameSite") { + if let Some(site) = value.as_str() { + if !matches!(site, "Lax" | "None" | "Strict") { + return Err(format!( + "Invalid argument type: Invalid enum value '{site}' - at `$.cookies[{index}].sameSite`" + )); + } + } else if !value.is_null() { + return Err(type_error( + "str | null", + value, + &format!(".cookies[{index}].sameSite"), + )); + } + cookie.insert("sameSite".to_string(), value.clone()); + } + output.push(Value::Object(cookie)); + } + Ok(output) +} + +fn charset(content_type: &str) -> Option<String> { + content_type + .split(';') + .find_map(|part| part.trim().strip_prefix("charset=")) + .map(|value| value.trim_matches(['\'', '"']).to_string()) +} + +fn decode_network_body(body: &Value, encoding: Option<&str>) -> Result<String, String> { + let raw = body.get("body").and_then(Value::as_str).unwrap_or_default(); + let bytes = if body + .get("base64Encoded") + .and_then(Value::as_bool) + .unwrap_or(false) + { + STANDARD.decode(raw).map_err(|e| e.to_string())? + } else { + raw.as_bytes().to_vec() + }; + let decoder = encoding + .and_then(|label| encoding_rs::Encoding::for_label(label.as_bytes())) + .unwrap_or(encoding_rs::UTF_8); + Ok(decoder.decode(&bytes).0.into_owned()) +} + +#[allow(clippy::type_complexity)] +fn tile_screenshot(bytes: &[u8], format: &str) -> Result<(Vec<Vec<u8>>, u32, u32, bool), String> { + let mut image = + image::load_from_memory(bytes).map_err(|e| format!("decoding screenshot: {e}"))?; + if image.width() > MAX_TILE_WIDTH { + let height = + python_round(image.height() as f64 * MAX_TILE_WIDTH as f64 / image.width() as f64) + .max(1) as u32; + image = image.resize_exact(MAX_TILE_WIDTH, height, FilterType::CatmullRom); + } + let (width, height) = image.dimensions(); + let needed = height.div_ceil(MAX_TILE_HEIGHT) as usize; + let mut tiles = Vec::with_capacity(needed.min(MAX_TILES)); + for top in (0..height) + .step_by(MAX_TILE_HEIGHT as usize) + .take(MAX_TILES) + { + let tile = image.crop_imm(0, top, width, (height - top).min(MAX_TILE_HEIGHT)); + let mut encoded = Vec::new(); + match format { + "png" => PngEncoder::new_with_quality( + &mut encoded, + CompressionType::Level(6), + PngFilterType::Adaptive, + ) + .write_image( + tile.as_bytes(), + tile.width(), + tile.height(), + tile.color().into(), + ) + .map_err(|e| format!("encoding PNG tile: {e}"))?, + "jpeg" | "jpg" => { + let tile = tile.to_rgb8(); + let mut encoder = jpeg_encoder::Encoder::new(&mut encoded, 75); + encoder.set_sampling_factor(jpeg_encoder::SamplingFactor::R_4_2_0); + encoder + .set_chroma_subsampling_method(jpeg_encoder::ChromaSubsamplingMethod::Average); + encoder + .encode( + tile.as_raw(), + tile.width() as u16, + tile.height() as u16, + jpeg_encoder::ColorType::Rgb, + ) + .map_err(|e| format!("encoding JPEG tile: {e}"))?; + } + other => return Err(format!("unsupported format: {other} (use png or jpeg)")), + } + tiles.push(encoded); + } + Ok((tiles, width, height, needed > MAX_TILES)) +} + +fn python_round(value: f64) -> i64 { + let floor = value.floor(); + let fraction = value - floor; + if fraction < 0.5 || (fraction == 0.5 && floor as i64 % 2 == 0) { + floor as i64 + } else { + floor as i64 + 1 + } +} + +fn ad_domains() -> &'static HashSet<&'static str> { + AD_DOMAINS.get_or_init(|| { + include_str!("../../vendor/scrapling-0.4.9-ad-domains.txt") + .lines() + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .collect() + }) +} + +fn domain_matches(host: &str, domain: &str) -> bool { + host.eq_ignore_ascii_case(domain) + || host + .strip_suffix(domain) + .is_some_and(|prefix| prefix.ends_with('.')) +} + +fn cloudflare_challenge(html: &str) -> Option<&'static str> { + for challenge in ["non-interactive", "managed", "interactive"] { + if html.contains(&format!("cType: '{challenge}'")) { + return Some(challenge); + } + } + let document = crate::scrapling::dom::parse(html); + crate::scrapling::query::css_query( + &document, + None, + "script[src*='challenges.cloudflare.com/turnstile/v']", + ) + .is_ok_and(|matches| !matches.is_empty()) + .then_some("embedded") +} + +fn challenge_frame_id(tree: &Value) -> Option<&str> { + let url = tree.pointer("/frame/url").and_then(Value::as_str); + if url.is_some_and(|url| { + ["http://", "https://"].into_iter().any(|scheme| { + url.strip_prefix(scheme).is_some_and(|url| { + url.starts_with("challenges.cloudflare.com/cdn-cgi/challenge-platform/") + }) + }) + }) { + return tree.pointer("/frame/id").and_then(Value::as_str); + } + tree.get("childFrames") + .and_then(Value::as_array) + .and_then(|children| children.iter().find_map(challenge_frame_id)) +} + +fn box_origin(quad: &Value) -> Option<(f64, f64)> { + let coordinates = quad + .as_array()? + .iter() + .map(Value::as_f64) + .collect::<Option<Vec<_>>>()?; + if coordinates.len() != 8 { + return None; + } + let xs = [ + coordinates[0], + coordinates[2], + coordinates[4], + coordinates[6], + ]; + let ys = [ + coordinates[1], + coordinates[3], + coordinates[5], + coordinates[7], + ]; + let min_x = xs.into_iter().fold(f64::INFINITY, f64::min); + let max_x = xs.into_iter().fold(f64::NEG_INFINITY, f64::max); + let min_y = ys.into_iter().fold(f64::INFINITY, f64::min); + let max_y = ys.into_iter().fold(f64::NEG_INFINITY, f64::max); + (max_x > min_x && max_y > min_y).then_some((min_x, min_y)) +} + +fn chromium_executable(config: &WorkerConfig, _real_chrome: bool) -> Result<PathBuf, String> { + if !config.scrapling.chromium_executable.is_empty() { + let path = PathBuf::from(&config.scrapling.chromium_executable); + return path.is_file().then_some(path).ok_or_else(|| { + "configured browser.scrapling.chromium_executable is not a file".to_string() + }); + } + let mut interactive = config.clone(); + interactive.executable.clear(); + crate::functions::doctor::detect_chromium(&interactive).ok_or_else(|| { + "no Chromium executable found; configure browser.scrapling.chromium_executable".to_string() + }) +} + +fn certify_chromium(path: &Path) -> Result<(), String> { + let version = crate::functions::doctor::chromium_version(path) + .ok_or_else(|| format!("could not read Chromium version from {}", path.display()))?; + if version + .split_whitespace() + .any(|value| value == CERTIFIED_CHROME_VERSION) + { + Ok(()) + } else { + Err(format!( + "compat mode requires Chrome {CERTIFIED_CHROME_VERSION}; {} reports {version}", + path.display() + )) + } +} + +fn python_string_hash(value: &str) -> u64 { + if value.is_empty() { + return 0; + } + let maximum = value.chars().map(u32::from).max().unwrap_or_default(); + let mut bytes = Vec::with_capacity(value.len()); + if maximum <= u32::from(u8::MAX) { + bytes.extend(value.chars().map(|character| character as u8)); + } else if maximum <= u32::from(u16::MAX) { + for character in value.chars() { + bytes.extend_from_slice(&(character as u16).to_ne_bytes()); + } + } else { + for character in value.chars() { + bytes.extend_from_slice(&u32::from(character).to_ne_bytes()); + } + } + let mut hasher = siphasher::sip::SipHasher13::new_with_keys(0, 0); + hasher.write(&bytes); + match hasher.finish() { + u64::MAX => u64::MAX - 1, + hash => hash, + } +} + +fn python_set_insert_clean(table: &mut [Option<(String, u64)>], key: String, hash: u64) { + let mask = table.len() - 1; + let mut perturb = hash as usize; + let mut index = perturb & mask; + loop { + if table[index].is_none() { + table[index] = Some((key, hash)); + return; + } + if index + 9 <= mask { + for offset in 1..=9 { + if table[index + offset].is_none() { + table[index + offset] = Some((key, hash)); + return; + } + } + } + perturb >>= 5; + index = index.wrapping_mul(5).wrapping_add(1).wrapping_add(perturb) & mask; + } +} + +fn python_set_order(values: impl IntoIterator<Item = String>) -> Vec<String> { + let mut table = vec![None; 8]; + let mut used = 0usize; + for key in values { + let hash = python_string_hash(&key); + let mask = table.len() - 1; + let mut perturb = hash as usize; + let mut index = perturb & mask; + let slot = loop { + let probes = if index + 9 <= mask { 9 } else { 0 }; + let mut unused = None; + for offset in 0..=probes { + match &table[index + offset] { + None => { + unused = Some(index + offset); + break; + } + Some((existing, existing_hash)) + if *existing_hash == hash && existing == &key => + { + unused = None; + break; + } + Some(_) => {} + } + } + if let Some(slot) = unused { + break Some(slot); + } + if (0..=probes).any(|offset| { + table[index + offset] + .as_ref() + .is_some_and(|(existing, existing_hash)| { + *existing_hash == hash && existing == &key + }) + }) { + break None; + } + perturb >>= 5; + index = index.wrapping_mul(5).wrapping_add(1).wrapping_add(perturb) & mask; + }; + let Some(slot) = slot else { + continue; + }; + table[slot] = Some((key, hash)); + used += 1; + if used * 5 >= mask * 3 { + let minimum = used * 4; + let mut size = 8usize; + while size <= minimum { + size *= 2; + } + let old = std::mem::replace(&mut table, vec![None; size]); + for (key, hash) in old.into_iter().flatten() { + python_set_insert_clean(&mut table, key, hash); + } + } + } + table.into_iter().flatten().map(|(key, _)| key).collect() +} + +fn scrapling_browser_args(options: &RawBrowserOptions, stealth: bool) -> Vec<String> { + if !stealth && options.extra_flags.is_empty() { + return DEFAULT_ARGS + .iter() + .map(|value| (*value).to_string()) + .collect(); + } + let mut arguments = DEFAULT_ARGS + .iter() + .map(|value| (*value).to_string()) + .collect::<Vec<_>>(); + if !options.extra_flags.is_empty() { + arguments.extend(options.extra_flags.iter().cloned()); + } else { + arguments.extend(STEALTH_ARGS.iter().map(|value| (*value).to_string())); + if options.block_webrtc { + arguments.extend([ + "--webrtc-ip-handling-policy=disable_non_proxied_udp".to_string(), + "--force-webrtc-ip-handling-policy".to_string(), + ]); + } + if !options.allow_webgl { + arguments.extend([ + "--disable-webgl".to_string(), + "--disable-webgl-image-chromium".to_string(), + "--disable-webgl2".to_string(), + ]); + } + if options.hide_canvas { + arguments.push("--fingerprinting-canvas-image-data-noise".to_string()); + } + } + python_set_order(arguments) +} + +fn launch_command( + executable: &Path, + profile: &Path, + options: &RawBrowserOptions, + stealth: bool, + gate: Option<&EgressGate>, +) -> Command { + let mut command = Command::new(executable); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + for argument in if stealth { + PATCHRIGHT_ARGS + } else { + PLAYWRIGHT_ARGS + } { + command.arg(argument); + } + if options.headless { + command + .arg("--headless") + .arg("--hide-scrollbars") + .arg("--mute-audio") + .arg("--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4"); + } + command.arg("--no-sandbox"); + command.args(scrapling_browser_args(options, stealth)); + if options.dns_over_https { + command.arg("--dns-over-https-templates=https://cloudflare-dns.com/dns-query"); + } + if let Some(proxy) = gate + .map(EgressGate::proxy_url) + .or_else(|| options.proxy.clone()) + { + command + .arg(format!("--proxy-server={proxy}")) + .arg("--proxy-bypass-list=<-loopback>"); + } + if gate.is_some() { + command + .arg("--disable-quic") + .arg("--force-webrtc-ip-handling-policy") + .arg("--webrtc-ip-handling-policy=disable_non_proxied_udp"); + } + command + .arg(format!("--user-data-dir={}", profile.display())) + .arg(crate::scrapling::cdp::REMOTE_DEBUGGING_PIPE_ARG) + .arg("about:blank"); + command +} + +struct TempProfile { + path: PathBuf, +} + +impl TempProfile { + fn new() -> Result<Self, String> { + let path = std::env::temp_dir().join(format!( + "browser-scrapling-{}", + uuid::Uuid::new_v4().simple() + )); + std::fs::create_dir(&path).map_err(|e| format!("creating browser profile: {e}"))?; + Ok(Self { path }) + } +} + +impl Drop for TempProfile { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + #[test] + fn validates_defaults_and_safe_refusals() { + let options = RawBrowserOptions::from_payload(&json!({})).unwrap(); + assert_eq!(options.retries, 3); + assert_eq!(options.timeout, Duration::from_secs(30)); + assert_eq!(referer(&options), GOOGLE_REFERER); + let options = RawBrowserOptions::from_payload(&json!({ + "proxy": "http://proxy", + "cdp_url": "ws://remote", + "extra_flags": ["--no-proxy-server"] + })) + .unwrap(); + let error = options.validate_policy(SecurityMode::Safe).unwrap_err(); + assert!(error.contains("proxy, cdp_url, extra_flags")); + assert!(options.validate_policy(SecurityMode::Compat).is_ok()); + assert_eq!(ad_domains().len(), 3526); + assert!(ad_domains().contains("doubleclick.net")); + assert!(domain_matches("a.b.doubleclick.net", "doubleclick.net")); + assert!(!domain_matches("notdoubleclick.net", "doubleclick.net")); + assert_eq!( + BLOCKED_RESOURCE_TYPES, + [ + "Font", + "Image", + "Media", + "TextTrack", + "WebSocket", + "Stylesheet" + ] + ); + assert_eq!( + cloudflare_challenge("<script>cType: 'managed'</script>"), + Some("managed") + ); + assert_eq!( + cloudflare_challenge( + "<script src='https://challenges.cloudflare.com/turnstile/v0/api.js'>" + ), + Some("embedded") + ); + assert_eq!( + cloudflare_challenge("<p>https://challenges.cloudflare.com/turnstile/v0/api.js</p>"), + None + ); + let tree = json!({ + "frame": {"id": "root", "url": "https://example.test"}, + "childFrames": [{ + "frame": { + "id": "challenge", + "url": "https://challenges.cloudflare.com/cdn-cgi/challenge-platform/h/g" + } + }] + }); + assert_eq!(challenge_frame_id(&tree), Some("challenge")); + assert_eq!( + box_origin(&json!([10, 20, 110, 20, 110, 70, 10, 70])), + Some((10.0, 20.0)) + ); + assert_eq!(box_origin(&json!([10, 20, 10, 20, 10, 20, 10, 20])), None); + } + + #[test] + fn browser_validation_matches_frozen_msgspec_errors() { + for (payload, expected) in [ + ( + json!({"retries": 0}), + "Invalid argument type: Expected `int` >= 1 - at `$.retries`", + ), + ( + json!({"retries": 11}), + "Invalid argument type: Expected `int` <= 10 - at `$.retries`", + ), + ( + json!({"max_pages": 0}), + "Invalid argument type: Expected `int` >= 1 - at `$.max_pages`", + ), + ( + json!({"timeout": -1}), + "Invalid argument type: Expected `float` >= 0.0 - at `$.timeout`", + ), + ( + json!({"wait_selector_state": "bogus"}), + "Invalid argument type: Invalid enum value 'bogus' - at `$.wait_selector_state`", + ), + ( + json!({"cookies": {}}), + "Invalid argument type: Expected `array | null`, got `object` - at `$.cookies`", + ), + ( + json!({"extra_flags": [1]}), + "Invalid argument type: Expected `str`, got `int` - at `$.extra_flags[0]`", + ), + ( + json!({"cookies": [{"name": 1}]}), + "Invalid argument type: Expected `str`, got `int` - at `$.cookies[0].name`", + ), + ( + json!({"extra_headers": {"x-test": 1}}), + "Invalid argument type: Expected `str`, got `int` - at `$.extra_headers[...]`", + ), + ( + json!({"proxy": {"username": "user"}}), + "Invalid argument type: Invalid proxy dictionary: Object missing required field `server`", + ), + ( + json!({"cdp_url": "http://example.test"}), + "Invalid argument type: CDP URL must use 'ws://' or 'wss://' scheme", + ), + ] { + assert_eq!(RawBrowserOptions::from_payload(&payload).unwrap_err(), expected); + } + let options = RawBrowserOptions::from_payload(&json!({ + "proxy": "http://user:pass@proxy.example:8080/path", + "cookies": [{"name": "a", "value": "b", "unknown": true}] + })) + .unwrap(); + assert_eq!(options.proxy.as_deref(), Some("http://proxy.example:8080")); + assert_eq!( + options.proxy_auth, + Some(("user".to_string(), "pass".to_string())) + ); + assert_eq!(options.cookies, vec![json!({"name": "a", "value": "b"})]); + } + + #[test] + fn screenshot_tiles_match_pillow_dimensions_pixels_and_cap() { + let source = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 800, + 1600, + image::Rgba([7, 11, 13, 255]), + )); + let mut png = Vec::new(); + source + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let (tiles, width, height, truncated) = tile_screenshot(&png, "png").unwrap(); + assert_eq!( + (width, height, tiles.len(), truncated), + (800, 1600, 2, false) + ); + assert_eq!( + image::load_from_memory(&tiles[0]) + .unwrap() + .get_pixel(0, 0) + .0, + [7, 11, 13, 255] + ); + + let tall = image::DynamicImage::new_rgb8(100, MAX_TILE_HEIGHT * 7); + let mut png = Vec::new(); + tall.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let (tiles, _, _, truncated) = tile_screenshot(&png, "png").unwrap(); + assert_eq!(tiles.len(), 6); + assert!(truncated); + assert_eq!(python_round(2.5), 2); + assert_eq!(python_round(3.5), 4); + } + + #[test] + fn launch_args_force_safe_proxy_and_webrtc() { + let options = RawBrowserOptions::from_payload(&json!({})).unwrap(); + let profile = Path::new("/tmp/profile"); + let gate = tokio::runtime::Runtime::new() + .unwrap() + .block_on(EgressGate::start(SsrfPolicy { + allow_loopback: true, + })) + .unwrap(); + let command = launch_command( + Path::new("/bin/true"), + profile, + &options, + false, + Some(&gate), + ); + let args = command + .get_args() + .map(|value| value.to_string_lossy().to_string()) + .collect::<Vec<_>>(); + assert!(args + .iter() + .any(|value| value.starts_with("--proxy-server=http://127.0.0.1:"))); + assert!(args.contains(&"--disable-quic".to_string())); + assert!(args.contains(&"--webrtc-ip-handling-policy=disable_non_proxied_udp".to_string())); + } + + #[test] + fn dynamic_launch_order_matches_the_frozen_worker() { + let options = RawBrowserOptions::from_payload(&json!({})).unwrap(); + let command = launch_command( + Path::new("/bin/true"), + Path::new("/tmp/profile"), + &options, + false, + None, + ); + let actual = command + .get_args() + .map(|value| value.to_string_lossy().to_string()) + .collect::<Vec<_>>(); + let expected = PLAYWRIGHT_ARGS + .iter() + .copied() + .chain([ + "--headless", + "--hide-scrollbars", + "--mute-audio", + "--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4", + "--no-sandbox", + ]) + .chain(DEFAULT_ARGS.iter().copied()) + .chain([ + "--user-data-dir=/tmp/profile", + "--remote-debugging-pipe", + "about:blank", + ]) + .map(str::to_string) + .collect::<Vec<_>>(); + assert_eq!(actual, expected); + } + + #[test] + fn cpython_set_order_matches_frozen_hash_seed_zero() { + assert_eq!(python_string_hash("a") as i64, 4_644_417_185_603_328_019); + assert_eq!(python_string_hash("abc") as i64, -4_594_863_902_769_663_758); + assert_eq!( + python_set_order( + DEFAULT_ARGS + .iter() + .map(|value| (*value).to_string()) + .chain(["--z-last", "--a-first", "--no-pings"].map(str::to_string)) + ), + [ + "--homepage=about:blank", + "--z-last", + "--a-first", + "--disable-breakpad", + "--disable-infobars", + "--no-default-browser-check", + "--disable-hang-monitor", + "--no-service-autorun", + "--password-store=basic", + "--no-pings", + "--disable-session-crashed-bubble", + "--disable-search-engine-choice-screen", + "--no-first-run", + ] + .map(str::to_string) + ); + } + + #[test] + fn stealth_launch_order_matches_the_frozen_worker() { + let options = RawBrowserOptions::from_payload(&json!({})).unwrap(); + let command = launch_command( + Path::new("/bin/true"), + Path::new("/tmp/profile"), + &options, + true, + None, + ); + let actual = command + .get_args() + .map(|value| value.to_string_lossy().to_string()) + .collect::<Vec<_>>(); + let expected = PATCHRIGHT_ARGS + .iter() + .copied() + .chain([ + "--headless", + "--hide-scrollbars", + "--mute-audio", + "--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4", + "--no-sandbox", + ]) + .chain(STEALTH_DEFAULT_ARGS.iter().copied()) + .chain([ + "--user-data-dir=/tmp/profile", + "--remote-debugging-pipe", + "about:blank", + ]) + .map(str::to_string) + .collect::<Vec<_>>(); + assert_eq!(actual, expected); + assert_eq!( + scrapling_browser_args(&options, true), + STEALTH_DEFAULT_ARGS + .iter() + .map(|value| (*value).to_string()) + .collect::<Vec<_>>() + ); + } + + #[test] + fn explicit_stealth_flags_preserve_the_frozen_override_quirk() { + let options = RawBrowserOptions::from_payload(&json!({ + "extra_flags": ["--z-last", "--a-first", "--no-pings"], + "block_webrtc": true, + "allow_webgl": false, + "hide_canvas": true + })) + .unwrap(); + assert_eq!( + scrapling_browser_args(&options, true), + [ + "--homepage=about:blank", + "--z-last", + "--a-first", + "--disable-breakpad", + "--disable-infobars", + "--no-default-browser-check", + "--disable-hang-monitor", + "--no-service-autorun", + "--password-store=basic", + "--no-pings", + "--disable-session-crashed-bubble", + "--disable-search-engine-choice-screen", + "--no-first-run", + ] + .map(str::to_string) + ); + } + + #[tokio::test] + async fn safe_browser_fetches_loopback_through_the_gate() { + let executable = std::env::var_os("SCRAPLING_CHROMIUM_EXECUTABLE") + .map(PathBuf::from) + .or_else(|| crate::functions::doctor::detect_chromium(&WorkerConfig::default())); + let Some(executable) = executable.filter(|path| certify_chromium(path).is_ok()) else { + return; + }; + let origin = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .unwrap(); + let address = origin.local_addr().unwrap(); + let server = tokio::spawn(async move { + for _ in 0..2 { + let (mut socket, _) = origin.accept().await.unwrap(); + let mut request = [0; 4096]; + let _ = socket.read(&mut request).await.unwrap(); + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: 48\r\nConnection: close\r\n\r\n<html><body><h1>raw cdp works</h1></body></html>", + ) + .await + .unwrap(); + } + }); + let mut config = WorkerConfig::default(); + config.scrapling.chromium_executable = executable.display().to_string(); + config.scrapling.allow_loopback = true; + let options = + RawBrowserOptions::from_payload(&json!({"retries": 1, "timeout": 5000})).unwrap(); + let page = tokio::time::timeout(Duration::from_secs(15), async { + let browser = RawBrowser::start(&config, &options, false, false).await?; + browser + .fetch(&format!("http://{address}/"), &options, false) + .await + }) + .await + .expect("raw browser fetch timed out") + .unwrap(); + assert_eq!(page.status, Some(200)); + assert!(page.html.contains("raw cdp works"), "{}", page.html); + server.abort(); + } +} diff --git a/browser/src/scrapling/schemas.rs b/browser/src/scrapling/schemas.rs new file mode 100644 index 000000000..b2daaaf1a --- /dev/null +++ b/browser/src/scrapling/schemas.rs @@ -0,0 +1,726 @@ +//! The 19 scrapling::* request/response schemas, mirrored byte-for-byte from +//! scrapling/src/schemas.py (goldens are generated FROM that file — this +//! module must match it, key order included; serde_json preserve_order). + +use serde_json::{json, Value}; + +pub struct FunctionSpec { + pub function_id: &'static str, + pub description: &'static str, + pub request: Value, + pub response: Value, +} + +fn selector_item() -> Value { + json!({ + "type": "object", + "properties": { + "name": {"type": "string"}, + "css": {"type": "string"}, + "xpath": {"type": "string"}, + "regex": {"type": "string"}, + "attr": {"type": "string", "description": "extract this attribute instead of text"}, + "html": {"type": "boolean", "description": "extract inner HTML instead of text"}, + "all": {"type": "boolean", "description": "return every match as a list"}, + }, + "required": ["name"], + }) +} + +fn selectors() -> Value { + json!({"type": "array", "items": selector_item()}) +} + +// css/xpath/regex results: first-match string, all-matches array, or null. +fn result_schema() -> Value { + json!({"type": ["array", "string", "null"], "items": {"type": ["string", "null"]}}) +} + +fn adaptive_props() -> Vec<(&'static str, Value)> { + vec![ + ( + "adaptive", + json!({"type": "boolean", "description": "relocate elements after a site change via saved identities"}), + ), + ( + "auto_save", + json!({"type": "boolean", "description": "save matched identities (defaults on when adaptive)"}), + ), + ( + "adaptive_domain", + json!({"type": "string", "description": "page URL/domain that keys saved identities"}), + ), + ] +} + +fn with_adaptive(mut base: serde_json::Map<String, Value>) -> Value { + for (k, v) in adaptive_props() { + base.insert(k.to_string(), v); + } + Value::Object(base) +} + +fn extract_request() -> Value { + let props = json!({ + "html": {"type": "string"}, + "selectors": selectors(), + }); + json!({ + "type": "object", + "properties": with_adaptive(props.as_object().unwrap().clone()), + "required": ["html", "selectors"], + }) +} + +fn query_request() -> Value { + let props = json!({ + "html": {"type": "string"}, + "query": {"type": "string"}, + "first": {"type": "boolean"}, + "attr": {"type": "string"}, + "identifier": {"type": "string", "description": "stable key for the saved element"}, + }); + json!({ + "type": "object", + "properties": with_adaptive(props.as_object().unwrap().clone()), + "required": ["html", "query"], + }) +} + +fn regex_request() -> Value { + json!({ + "type": "object", + "properties": { + "html": {"type": "string"}, + "pattern": {"type": "string"}, + "first": {"type": "boolean"}, + }, + "required": ["html", "pattern"], + }) +} + +fn find_similar_request() -> Value { + json!({ + "type": "object", + "properties": { + "html": {"type": "string"}, + "anchor": {"type": "string", "description": "CSS selector to one example element"}, + "similarity_threshold": {"type": "number"}, + "match_text": {"type": "boolean"}, + "selectors": selectors(), + }, + "required": ["html", "anchor"], + }) +} + +// One matched element: text, inner HTML, attributes, and the auto-generated +// CSS/XPath selectors that locate it. +fn element() -> Value { + json!({ + "type": "object", + "properties": { + "tag": {"type": "string"}, + "text": {"type": "string"}, + "html": {"type": "string"}, + "attrs": {"type": "object"}, + "css": {"type": "string"}, + "xpath": {"type": "string"}, + }, + }) +} + +fn elements_response() -> Value { + json!({ + "type": "object", + "properties": { + "count": {"type": "integer"}, + "items": {"type": "array", "items": element()}, + }, + }) +} + +fn find_request() -> Value { + json!({ + "type": "object", + "properties": { + "html": {"type": "string"}, + "tag": { + "type": ["string", "array"], + "items": {"type": "string"}, + "description": "tag name or list of tag names", + }, + "attrs": {"type": "object", "description": "attribute filters, e.g. {\"class\": \"card\"}"}, + "text_regex": {"type": "string", "description": "keep only elements whose text matches this regex"}, + "first": {"type": "boolean"}, + "limit": {"type": "integer"}, + }, + "required": ["html"], + }) +} + +fn find_by_text_request() -> Value { + json!({ + "type": "object", + "properties": { + "html": {"type": "string"}, + "text": {"type": "string"}, + "partial": {"type": "boolean", "description": "match elements that contain the text"}, + "case_sensitive": {"type": "boolean"}, + "clean_match": {"type": "boolean", "description": "ignore surrounding/collapsing whitespace"}, + "first": {"type": "boolean"}, + "limit": {"type": "integer"}, + }, + "required": ["html", "text"], + }) +} + +fn find_by_regex_request() -> Value { + json!({ + "type": "object", + "properties": { + "html": {"type": "string"}, + "pattern": {"type": "string"}, + "case_sensitive": {"type": "boolean"}, + "clean_match": {"type": "boolean"}, + "first": {"type": "boolean"}, + "limit": {"type": "integer"}, + }, + "required": ["html", "pattern"], + }) +} + +fn describe_request() -> Value { + json!({ + "type": "object", + "properties": { + "html": {"type": "string"}, + "query": {"type": "string"}, + "kind": {"type": "string", "enum": ["css", "xpath"]}, + }, + "required": ["html", "query"], + }) +} + +// element properties + full_css/full_xpath/classes/parent_tag/children/siblings. +fn describe_response() -> Value { + let mut props = element()["properties"].as_object().unwrap().clone(); + props.insert("full_css".to_string(), json!({"type": "string"})); + props.insert("full_xpath".to_string(), json!({"type": "string"})); + props.insert( + "classes".to_string(), + json!({"type": "array", "items": {"type": "string"}}), + ); + props.insert( + "parent_tag".to_string(), + json!({"type": ["string", "null"]}), + ); + props.insert("children".to_string(), json!({"type": "integer"})); + props.insert("siblings".to_string(), json!({"type": "integer"})); + json!({ + "type": "object", + "properties": { + "found": {"type": "boolean"}, + "element": { + "type": "object", + "properties": Value::Object(props), + }, + }, + }) +} + +fn markdown_request() -> Value { + json!({ + "type": "object", + "properties": { + "html": {"type": "string"}, + "format": {"type": "string", "enum": ["markdown", "text", "html"]}, + "css_selector": {"type": "string", "description": "convert only the subtree matching this CSS selector"}, + "main_content_only": {"type": "boolean", "description": "strip nav/scripts/hidden nodes first"}, + }, + "required": ["html"], + }) +} + +// --- shared fragments for fetch/stealthy-fetch/dynamic-fetch/session-fetch/crawl +// (mirrors schemas.py's _BULK_TARGET/_BROWSER_WAIT/_CONTENT_OUT/_COMMON_OUT) --- + +fn obj(props: Vec<(&'static str, Value)>) -> Value { + Value::Object(props.into_iter().map(|(k, v)| (k.to_string(), v)).collect()) +} + +fn bulk_target_props() -> Vec<(&'static str, Value)> { + vec![ + ("url", json!({"type": "string"})), + ( + "urls", + json!({"type": "array", "items": {"type": "string"}}), + ), + ] +} + +fn browser_wait_props() -> Vec<(&'static str, Value)> { + vec![ + ("headless", json!({"type": "boolean"})), + ("network_idle", json!({"type": "boolean"})), + ("load_dom", json!({"type": "boolean"})), + ( + "timeout", + json!({"type": "number", "description": "milliseconds (browser fetcher)"}), + ), + ( + "wait", + json!({"type": "number", "description": "extra ms to wait after load"}), + ), + ("wait_selector", json!({"type": "string"})), + ( + "wait_selector_state", + json!({"type": "string", "enum": ["attached", "detached", "visible", "hidden"]}), + ), + ("disable_resources", json!({"type": "boolean"})), + ("block_ads", json!({"type": "boolean"})), + ( + "blocked_domains", + json!({"type": "array", "items": {"type": "string"}}), + ), + ("proxy", json!({"type": "string"})), + ("useragent", json!({"type": "string"})), + ("cookies", json!({"type": "object"})), + ("extra_headers", json!({"type": "object"})), + ("google_search", json!({"type": "boolean"})), + ("capture_xhr", json!({"type": "string"})), + ("locale", json!({"type": "string"})), + ("timezone_id", json!({"type": "string"})), + ("dns_over_https", json!({"type": "boolean"})), + ( + "extra_flags", + json!({"type": "array", "items": {"type": "string"}}), + ), + ("max_pages", json!({"type": "integer"})), + ("retries", json!({"type": "integer"})), + ("retry_delay", json!({"type": "number"})), + ] +} + +// Post-fetch content rendering (reuses Scrapling's Convertor): compact the +// page to markdown/text instead of dumping raw HTML. +fn content_out_props() -> Vec<(&'static str, Value)> { + vec![ + ( + "format", + json!({"type": "string", "enum": ["markdown", "text"], "description": "render page body to this format"}), + ), + ( + "main_content_only", + json!({"type": "boolean", "description": "strip nav/scripts/hidden before rendering"}), + ), + ( + "css_selector", + json!({"type": "string", "description": "scope the render to this CSS subtree (e.g. a page's content div)"}), + ), + ] +} + +// selectors/include_html + content_out: shared tail of every fetch-family request. +fn common_out_props() -> Vec<(&'static str, Value)> { + let mut props = vec![ + ("selectors", selectors()), + ("include_html", json!({"type": "boolean"})), + ]; + props.extend(content_out_props()); + props +} + +// fetch/stealthy-fetch/dynamic-fetch/session-fetch: identical page/extraction output. +fn fetch_response() -> Value { + json!({ + "type": "object", + "properties": { + "status": {"type": ["integer", "null"]}, + "url": {"type": "string"}, + "headers": {"type": "object"}, + "cookies": {"type": "object"}, + "encoding": {"type": ["string", "null"]}, + "extracted": {"type": "object"}, + "html": {"type": "string"}, + "content": {"type": "string", "description": "markdown/text render when `format` requested"}, + "format": {"type": "string"}, + "captured_xhr": {"type": "array", "items": {"type": "object"}}, + "results": {"type": "array", "items": {"type": "object"}}, + "error": {"type": "string"}, + }, + }) +} + +fn fetch_request() -> Value { + let mut props = bulk_target_props(); + props.extend([ + ("method", json!({"type": "string", "enum": ["get", "post", "put", "delete"]})), + ("headers", json!({"type": "object"})), + ("params", json!({"type": "object"})), + ("data", json!({"type": "object"})), + ("json", json!({"type": "object"})), + ("cookies", json!({"type": "object"})), + ("proxy", json!({"type": "string"})), + ( + "proxies", + json!({"type": "object", "description": "per-scheme proxies, e.g. {\"https\": \"http://...\"}"}), + ), + ( + "proxy_auth", + json!({"type": "array", "items": {"type": "string"}, "description": "[user, password]"}), + ), + ("impersonate", json!({"type": "string", "description": "TLS/UA fingerprint, e.g. 'chrome'"})), + ("timeout", json!({"type": "number", "description": "seconds (HTTP fetcher)"})), + ("follow_redirects", json!({"type": "boolean"})), + ("max_redirects", json!({"type": "integer"})), + ("stealthy_headers", json!({"type": "boolean"})), + ("http3", json!({"type": "boolean"})), + ("verify", json!({"type": "boolean"})), + ("retries", json!({"type": "integer"})), + ("retry_delay", json!({"type": "number"})), + ]); + props.extend(common_out_props()); + json!({"type": "object", "properties": obj(props)}) +} + +fn stealthy_fetch_request() -> Value { + let mut props = bulk_target_props(); + props.extend(browser_wait_props()); + props.extend([ + ("solve_cloudflare", json!({"type": "boolean"})), + ("block_webrtc", json!({"type": "boolean"})), + ("hide_canvas", json!({"type": "boolean"})), + ("allow_webgl", json!({"type": "boolean"})), + ]); + props.extend(common_out_props()); + json!({"type": "object", "properties": obj(props)}) +} + +fn dynamic_fetch_request() -> Value { + let mut props = bulk_target_props(); + props.extend(browser_wait_props()); + props.extend([ + ("real_chrome", json!({"type": "boolean"})), + ("cdp_url", json!({"type": "string"})), + ]); + props.extend(common_out_props()); + json!({"type": "object", "properties": obj(props)}) +} + +fn screenshot_request() -> Value { + json!({ + "type": "object", + "properties": { + "url": {"type": "string"}, + "fetcher": {"type": "string", "enum": ["dynamic", "stealthy"]}, + "full_page": {"type": "boolean"}, + "format": {"type": "string", "enum": ["png", "jpeg"]}, + "headless": {"type": "boolean"}, + "network_idle": {"type": "boolean"}, + "timeout": {"type": "number"}, + "wait_selector": {"type": "string"}, + "proxy": {"type": "string"}, + }, + "required": ["url"], + }) +} + +// Harness content blocks: image tiles + a trailing text caption. The harness +// forwards `content` verbatim so the model sees images, never base64 text. +fn screenshot_response() -> Value { + json!({ + "type": "object", + "properties": { + "content": { + "type": "array", + "description": "image blocks (one per tile, width<=1024/height<=1536) + a text caption", + "items": { + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["image", "text"]}, + "mime": {"type": "string"}, + "data": {"type": "string", "description": "base64 image bytes (image blocks)"}, + "text": {"type": "string"}, + }, + "required": ["type"], + }, + }, + "mime": {"type": "string"}, + "url": {"type": "string"}, + }, + }) +} + +fn session_open_request() -> Value { + json!({ + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["http", "dynamic", "stealthy"], "description": "session engine"}, + "impersonate": {"type": "string"}, + "headers": {"type": "object"}, + "proxy": {"type": "string"}, + "proxies": {"type": "object"}, + "headless": {"type": "boolean"}, + "useragent": {"type": "string"}, + "solve_cloudflare": {"type": "boolean"}, + "real_chrome": {"type": "boolean"}, + "timeout": {"type": "number"}, + "capture_xhr": {"type": "string", "description": "regex; capture matching XHRs (browser sessions)"}, + }, + }) +} + +fn session_open_response() -> Value { + json!({"type": "object", "properties": {"session_id": {"type": "string"}, "type": {"type": "string"}}}) +} + +fn session_fetch_request() -> Value { + let mut props = vec![ + ("session_id", json!({"type": "string"})), + ("url", json!({"type": "string"})), + ( + "method", + json!({"type": "string", "enum": ["get", "post", "put", "delete"]}), + ), + ("headers", json!({"type": "object"})), + ("params", json!({"type": "object"})), + ("data", json!({"type": "object"})), + ("json", json!({"type": "object"})), + ("wait_selector", json!({"type": "string"})), + ]; + props.extend(common_out_props()); + json!({ + "type": "object", + "properties": obj(props), + "required": ["session_id", "url"], + }) +} + +fn session_close_request() -> Value { + json!({"type": "object", "properties": {"session_id": {"type": "string"}}, "required": ["session_id"]}) +} + +fn session_close_response() -> Value { + json!({"type": "object", "properties": {"closed": {"type": "boolean"}}}) +} + +fn session_list_request() -> Value { + json!({ + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["http", "dynamic", "stealthy"], "description": "filter by type"}, + }, + }) +} + +fn session_list_response() -> Value { + json!({ + "type": "object", + "properties": { + "sessions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "session_id": {"type": "string"}, + "type": {"type": "string"}, + "created_at": {"type": "number"}, + "last_used": {"type": "number"}, + "idle_s": {"type": "number"}, + }, + }, + }, + }, + }) +} + +fn crawl_request() -> Value { + let mut props = vec![ + ( + "start_urls", + json!({"type": "array", "items": {"type": "string"}}), + ), + ( + "url", + json!({"type": "string", "description": "single start URL (alternative to start_urls)"}), + ), + ( + "fetcher", + json!({"type": "string", "enum": ["http", "stealthy", "dynamic"]}), + ), + ("selectors", selectors()), + ( + "allowed_domains", + json!({"type": "array", "items": {"type": "string"}, "description": "only follow links on these hosts"}), + ), + ( + "same_domain", + json!({"type": "boolean", "description": "follow only same-host links (default true)"}), + ), + ("max_pages", json!({"type": "integer"})), + ("max_depth", json!({"type": "integer"})), + ("concurrency", json!({"type": "integer"})), + ( + "download_delay", + json!({"type": "number", "description": "seconds to wait between crawl rounds"}), + ), + ]; + props.extend(content_out_props()); + props.extend([ + ("include_html", json!({"type": "boolean"})), + ("impersonate", json!({"type": "string"})), + ( + "stream_name", + json!({"type": "string", "description": "stream to emit items on (default browser::crawl)"}), + ), + ]); + json!({"type": "object", "properties": obj(props)}) +} + +fn crawl_response() -> Value { + json!({ + "type": "object", + "properties": { + "stats": { + "type": "object", + "properties": { + "crawled": {"type": "integer"}, + "items": {"type": "integer"}, + "errors": {"type": "integer"}, + "stopped": {"type": "string"}, + }, + }, + "items": {"type": "array", "items": {"type": "object"}, "description": "a small sample of streamed items"}, + "stream": { + "type": "object", + "properties": {"name": {"type": "string"}, "group_id": {"type": "string"}}, + "description": "read the full item stream via stream::on with this name + group_id", + }, + }, + }) +} + +pub fn catalog() -> Vec<FunctionSpec> { + vec![ + FunctionSpec { + function_id: "browser::fetch", + description: "Fast HTTP fetch, TLS impersonation: get/post/put/delete, inline extraction, bulk `urls`.", + request: fetch_request(), + response: fetch_response(), + }, + FunctionSpec { + function_id: "browser::stealthy-fetch", + description: "Camoufox stealth browser: solves Cloudflare, hardens WebRTC/canvas; extraction + bulk.", + request: stealthy_fetch_request(), + response: fetch_response(), + }, + FunctionSpec { + function_id: "browser::dynamic-fetch", + description: "Playwright/Chromium fetch: JS render, waits, XHR capture, CDP; extraction + bulk.", + request: dynamic_fetch_request(), + response: fetch_response(), + }, + FunctionSpec { + function_id: "browser::screenshot-url", + description: "Capture a page screenshot as image content blocks via a browser fetcher (dynamic or stealthy).", + request: screenshot_request(), + response: screenshot_response(), + }, + FunctionSpec { + function_id: "browser::extract", + description: "Parse HTML with a selector list (css/xpath/regex, text/attr/html, all-or-first).", + request: extract_request(), + response: json!({"type": "object", "properties": {"extracted": {"type": "object"}}}), + }, + FunctionSpec { + function_id: "browser::css", + description: "One CSS query over HTML; first-or-all; `attr` pulls an attribute else text.", + request: query_request(), + response: json!({"type": "object", "properties": {"result": result_schema()}}), + }, + FunctionSpec { + function_id: "browser::xpath", + description: "One XPath query over HTML; first-or-all; `attr` pulls an attribute else text.", + request: query_request(), + response: json!({"type": "object", "properties": {"result": result_schema()}}), + }, + FunctionSpec { + function_id: "browser::regex", + description: "Run a regex over the visible text of provided HTML; `first` returns the first match, else all.", + request: regex_request(), + response: json!({"type": "object", "properties": {"result": result_schema()}}), + }, + FunctionSpec { + function_id: "browser::find-similar", + description: "Structural auto-match: given one example element, return it plus similar elements.", + request: find_similar_request(), + response: json!({ + "type": "object", + "properties": { + "count": {"type": "integer"}, + "items": {"type": "array", "items": {"type": "object"}}, + }, + }), + }, + FunctionSpec { + function_id: "browser::find", + description: "Find elements by tag/attribute filters (+ optional text regex); BeautifulSoup-style.", + request: find_request(), + response: elements_response(), + }, + FunctionSpec { + function_id: "browser::find-by-text", + description: "Find elements whose visible text matches a string (exact or `partial`).", + request: find_by_text_request(), + response: elements_response(), + }, + FunctionSpec { + function_id: "browser::find-by-regex", + description: "Find elements whose visible text matches a regex pattern.", + request: find_by_regex_request(), + response: elements_response(), + }, + FunctionSpec { + function_id: "browser::describe", + description: "Describe the first css/xpath match: attrs, generated selectors, class list, DOM context.", + request: describe_request(), + response: describe_response(), + }, + FunctionSpec { + function_id: "browser::to-markdown", + description: "Convert HTML to compact Markdown (or text/html); optional CSS scope + main-content clean.", + request: markdown_request(), + response: json!({"type": "object", "properties": {"format": {"type": "string"}, "content": {"type": "string"}}}), + }, + FunctionSpec { + function_id: "browser::session-open", + description: "Open a persistent HTTP/browser session; returns a session_id that reuses cookies + state.", + request: session_open_request(), + response: session_open_response(), + }, + FunctionSpec { + function_id: "browser::session-fetch", + description: "Fetch a URL on an open session (reuses its cookies/browser); same page/extraction output.", + request: session_fetch_request(), + response: fetch_response(), + }, + FunctionSpec { + function_id: "browser::session-close", + description: "Close a session and free its browser/connection.", + request: session_close_request(), + response: session_close_response(), + }, + FunctionSpec { + function_id: "browser::session-list", + description: "List open sessions with their type and idle time.", + request: session_list_request(), + response: session_list_response(), + }, + FunctionSpec { + function_id: "browser::crawl", + description: "BFS-crawl from start_urls (follow same-domain links), extract per page, stream items.", + request: crawl_request(), + response: crawl_response(), + }, + ] +} diff --git a/browser/src/scrapling/selgen.rs b/browser/src/scrapling/selgen.rs new file mode 100644 index 000000000..1b68618c4 --- /dev/null +++ b/browser/src/scrapling/selgen.rs @@ -0,0 +1,149 @@ +//! scrapling's generated-selector algorithm (core/mixins.py), ported verbatim. + +use crate::scrapling::dom::{self, ElementRef}; + +fn general_selection(el: ElementRef, css: bool, full_path: bool) -> String { + let mut parts: Vec<String> = Vec::new(); + let mut target = el; + while let Some(parent) = dom::parent_element(target) { + if let Some(id) = target.attr("id").filter(|s| !s.is_empty()) { + if css { + parts.push(format!("#{id}")); + } else if full_path { + parts.push(format!("*[@id='{id}']")); + } else { + parts.push(format!("[@id='{id}']")); + } + if !full_path { + parts.reverse(); + return if css { + parts.join(" > ") + } else { + format!("//*{}", parts.join("/")) + }; + } + } else { + let tag = target.name(); + let mut index = 0usize; + for sibling in dom::element_children(parent) { + if sibling.name() == tag { + index += 1; + } + if sibling.id() == target.id() { + break; + } + } + let mut part = tag.to_string(); + if index > 1 { + if css { + part.push_str(&format!(":nth-of-type({index})")); + } else { + part.push_str(&format!("[{index}]")); + } + } + parts.push(part); + } + target = parent; + if target.name() == "html" { + break; + } + } + parts.reverse(); + if css { + parts.join(" > ") + } else { + format!("//{}", parts.join("/")) + } +} + +pub fn css_selector(el: ElementRef) -> String { + general_selection(el, true, false) +} +pub fn full_css_selector(el: ElementRef) -> String { + general_selection(el, true, true) +} +pub fn xpath_selector(el: ElementRef) -> String { + general_selection(el, false, false) +} +pub fn full_xpath_selector(el: ElementRef) -> String { + general_selection(el, false, true) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn matches<'a>(d: &'a dom::Doc, selector: &str) -> Vec<ElementRef<'a>> { + crate::scrapling::query::css_query(d, None, selector) + .unwrap() + .into_iter() + .filter_map(|result| match result { + crate::scrapling::query::QueryResult::Element(element) => Some(element), + _ => None, + }) + .collect() + } + + fn first<'a>(d: &'a dom::Doc, selector: &str) -> ElementRef<'a> { + matches(d, selector)[0] + } + + const HTML: &str = r#"<html><body><h1 class="t">Hello</h1><ul><li><a href="/a">Apple</a></li><li><a href="/b">Banana</a></li></ul></body></html>"#; + + #[test] + fn plain_chain_stops_below_html() { + let d = dom::parse(HTML); + assert_eq!(css_selector(first(&d, "a")), "body > ul > li > a"); + assert_eq!(xpath_selector(first(&d, "a")), "//body/ul/li/a"); + } + + #[test] + fn nth_of_type_only_when_index_gt_1() { + let d = dom::parse(HTML); + let second_li = matches(&d, "li")[1]; + assert_eq!(css_selector(second_li), "body > ul > li:nth-of-type(2)"); + assert_eq!(xpath_selector(second_li), "//body/ul/li[2]"); + } + + #[test] + fn id_short_circuits_short_variants() { + let d = dom::parse(r#"<html><body><div id="main"><p>x</p><p>y</p></div></body></html>"#); + let p2 = matches(&d, "p")[1]; + assert_eq!(css_selector(p2), "#main > p:nth-of-type(2)"); + assert_eq!(xpath_selector(p2), "//*[@id='main']/p[2]"); + // full variants do NOT short-circuit: + assert_eq!(full_css_selector(p2), "body > #main > p:nth-of-type(2)"); + assert_eq!(full_xpath_selector(p2), "//body/*[@id='main']/p[2]"); + } + + #[test] + fn html_element_yields_empty() { + let d = dom::parse(HTML); + assert_eq!(css_selector(d.root()), ""); + } + + #[test] + fn empty_id_falls_through_to_tag_addressing() { + // Python's `if target.attrib.get("id"):` is a truthiness check, so + // `id=""` must NOT take the id branch (verified against scrapling + // 0.4.9: same output for short and full variants). + let d = dom::parse(r#"<html><body><div id=""><p>x</p><p>y</p></div></body></html>"#); + let p2 = matches(&d, "p")[1]; + assert_eq!(css_selector(p2), "body > div > p:nth-of-type(2)"); + assert_eq!(xpath_selector(p2), "//body/div/p[2]"); + assert_eq!(full_css_selector(p2), "body > div > p:nth-of-type(2)"); + assert_eq!(full_xpath_selector(p2), "//body/div/p[2]"); + } + + #[test] + fn html_element_xpath_variants_yield_double_slash() { + // css stays "" (no guard needed), but xpath's unconditional + // "//" + parts.join("/") naturally yields "//" for an empty parts + // list (verified against scrapling 0.4.9). + let d = dom::parse(HTML); + assert_eq!(css_selector(d.root()), ""); + assert_eq!(xpath_selector(d.root()), "//"); + assert_eq!(full_css_selector(d.root()), ""); + assert_eq!(full_xpath_selector(d.root()), "//"); + } +} diff --git a/browser/src/scrapling/sessions.rs b/browser/src/scrapling/sessions.rs new file mode 100644 index 000000000..a5404d4cc --- /dev/null +++ b/browser/src/scrapling/sessions.rs @@ -0,0 +1,909 @@ +//! Private persistent-session registry for `browser::session-*`. + +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::{Arc, Mutex}; + +use serde_json::{json, Map, Value}; +use tokio::sync::{mpsc, oneshot}; + +use crate::config::WorkerConfig; +#[cfg(feature = "scrapling-compat")] +use crate::scrapling::fetch::CompatSession; +use crate::scrapling::fetch::HttpMode; +use crate::scrapling::raw_browser::{RawBrowser, RawBrowserOptions}; +use crate::session::now_ms; + +pub(crate) type Job = Pin<Box<dyn Future<Output = Result<Value, String>> + Send + 'static>>; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SessionType { + Http, + Dynamic, + Stealthy, +} + +impl SessionType { + pub fn as_str(self) -> &'static str { + match self { + Self::Http => "http", + Self::Dynamic => "dynamic", + Self::Stealthy => "stealthy", + } + } +} + +pub fn parse_type(payload: &Value) -> Result<SessionType, String> { + let Some(value) = payload.get("type") else { + return Ok(SessionType::Http); + }; + match value.as_str() { + Some("http") => Ok(SessionType::Http), + Some("dynamic") => Ok(SessionType::Dynamic), + Some("stealthy") => Ok(SessionType::Stealthy), + _ => Err(format!( + "unknown session type: {} (use http|dynamic|stealthy)", + python_repr(value) + )), + } +} + +fn python_repr(value: &Value) -> String { + match value { + Value::Null => "None".to_string(), + Value::Bool(true) => "True".to_string(), + Value::Bool(false) => "False".to_string(), + Value::String(value) => format!("'{value}'"), + other => other.to_string(), + } +} + +#[derive(Debug)] +pub struct HttpBackend { + pub jar: Arc<reqwest::cookie::Jar>, + pub mode: HttpMode, + #[cfg(feature = "scrapling-compat")] + pub(crate) compat: Option<CompatSession>, + constructor: Value, +} + +impl HttpBackend { + #[cfg(test)] + fn new(payload: &Value) -> Self { + Self::new_for_mode(payload, HttpMode::Safe).expect("safe HTTP backend is infallible") + } + + fn new_for_mode(payload: &Value, mode: HttpMode) -> Result<Self, String> { + #[cfg(not(feature = "scrapling-compat"))] + if mode == HttpMode::Compat { + return Err( + "browser::fetch compat HTTP engine is not compiled into this binary".to_string(), + ); + } + Ok(Self { + jar: Arc::new(reqwest::cookie::Jar::default()), + mode, + #[cfg(feature = "scrapling-compat")] + compat: (mode == HttpMode::Compat) + .then(CompatSession::new) + .transpose()?, + constructor: constructor_config(payload), + }) + } + + pub fn request(&self, payload: &Value) -> Value { + merge_request(&self.constructor, payload) + } +} + +pub struct BrowserBackend { + pub browser: Arc<RawBrowser>, + constructor: Value, + pub stealth: bool, + pub security_mode: crate::config::SecurityMode, +} + +impl BrowserBackend { + pub fn request(&self, payload: &Value) -> Value { + merge_browser_request(&self.constructor, payload) + } +} + +enum Backend { + Http(Arc<HttpBackend>), + Browser(Arc<BrowserBackend>), +} + +enum Command { + Run { + job: Job, + response: oneshot::Sender<Result<Value, String>>, + }, + Close { + response: oneshot::Sender<()>, + }, +} + +struct Entry { + id: String, + session_type: SessionType, + created_ms: i64, + last_used_ms: AtomicI64, + compat_only: bool, + backend: Backend, + commands: mpsc::UnboundedSender<Command>, +} + +impl Entry { + fn info(&self, observed_ms: i64) -> Value { + let last = self.last_used_ms.load(Ordering::Relaxed); + let idle = (observed_ms - last).max(0) as f64 / 1000.0; + json!({ + "session_id": self.id, + "type": self.session_type.as_str(), + "created_at": self.created_ms as f64 / 1000.0, + "last_used": last as f64 / 1000.0, + "idle_s": (idle * 10.0).round() / 10.0, + }) + } +} + +#[derive(Default)] +struct State { + entries: HashMap<String, Arc<Entry>>, + insertion_order: Vec<String>, + pending: usize, +} + +pub struct Registry { + state: Mutex<State>, + max_sessions: usize, + idle_timeout_ms: u64, +} + +impl Registry { + pub fn new(max_sessions: u64, idle_timeout_s: u64) -> Self { + Self { + state: Mutex::new(State::default()), + max_sessions: usize::try_from(max_sessions).unwrap_or(usize::MAX), + idle_timeout_ms: idle_timeout_s.saturating_mul(1_000), + } + } + + fn lock(&self) -> std::sync::MutexGuard<'_, State> { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + async fn open_with<F>( + &self, + session_type: SessionType, + compat_only: bool, + construct: F, + ) -> Result<Value, String> + where + F: Future<Output = Result<Backend, String>>, + { + { + let mut state = self.lock(); + if state.entries.len() + state.pending >= self.max_sessions { + return Err(format!( + "session limit reached ({}); close one first", + self.max_sessions + )); + } + state.pending += 1; + } + + // Decrement on EVERY exit — error, panic or cancellation. A missed + // decrement leaks a slot forever and eventually bricks session-open. + struct PendingGuard<'a>(&'a Registry); + impl Drop for PendingGuard<'_> { + fn drop(&mut self) { + self.0.lock().pending -= 1; + } + } + let pending = PendingGuard(self); + let backend = construct.await; + drop(pending); + let mut state = self.lock(); + let backend = backend?; + let id = uuid::Uuid::new_v4().simple().to_string(); + let now = now_ms(); + let (commands, receiver) = mpsc::unbounded_channel(); + let entry = Arc::new(Entry { + id: id.clone(), + session_type, + created_ms: now, + last_used_ms: AtomicI64::new(now), + compat_only, + backend, + commands, + }); + tokio::spawn(run_actor(entry.clone(), receiver)); + state.insertion_order.push(id.clone()); + state.entries.insert(id.clone(), entry); + Ok(json!({"session_id": id, "type": session_type.as_str()})) + } + + pub async fn open_http( + &self, + payload: &Value, + compat_only: bool, + mode: HttpMode, + ) -> Result<Value, String> { + let payload = payload.clone(); + self.open_with(SessionType::Http, compat_only, async move { + Ok(Backend::Http(Arc::new(HttpBackend::new_for_mode( + &payload, mode, + )?))) + }) + .await + } + + pub async fn open_browser( + &self, + session_type: SessionType, + payload: &Value, + compat_only: bool, + config: Arc<WorkerConfig>, + ) -> Result<Value, String> { + let constructor = browser_constructor_config(payload); + let payload = payload.clone(); + let stealth = session_type == SessionType::Stealthy; + self.open_with(session_type, compat_only, async move { + let mut options = RawBrowserOptions::from_payload(&payload)?; + options.clamp_durations(config.max_timeout_ms); + let browser = RawBrowser::start(&config, &options, stealth, true).await?; + Ok(Backend::Browser(Arc::new(BrowserBackend { + browser: Arc::new(browser), + constructor, + stealth, + security_mode: config.scrapling.security_mode, + }))) + }) + .await + } + + pub fn http_backend(&self, id: &str) -> Result<Arc<HttpBackend>, String> { + let state = self.lock(); + let entry = state + .entries + .get(id) + .ok_or_else(|| format!("unknown session: {id}"))?; + match &entry.backend { + Backend::Http(backend) => Ok(backend.clone()), + Backend::Browser(_) => Err(format!("session {id} is not an HTTP session")), + } + } + + pub fn browser_backend(&self, id: &str) -> Result<Arc<BrowserBackend>, String> { + let state = self.lock(); + let entry = state + .entries + .get(id) + .ok_or_else(|| format!("unknown session: {id}"))?; + match &entry.backend { + Backend::Browser(backend) => Ok(backend.clone()), + Backend::Http(_) => Err(format!("session {id} is not a browser session")), + } + } + + pub fn session_type(&self, id: &str) -> Result<SessionType, String> { + self.lock() + .entries + .get(id) + .map(|entry| entry.session_type) + .ok_or_else(|| format!("unknown session: {id}")) + } + + pub fn uses_compat_only_options(&self, id: &str) -> Result<bool, String> { + self.lock() + .entries + .get(id) + .map(|entry| entry.compat_only) + .ok_or_else(|| format!("unknown session: {id}")) + } + + pub async fn run(&self, id: &str, job: Job) -> Result<Value, String> { + let response = { + let state = self.lock(); + let entry = state + .entries + .get(id) + .ok_or_else(|| format!("unknown session: {id}"))?; + let (send, receive) = oneshot::channel(); + entry + .commands + .send(Command::Run { + job, + response: send, + }) + .map_err(|_| format!("unknown session: {id}"))?; + receive + }; + response + .await + .map_err(|_| format!("unknown session: {id}"))? + } + + pub async fn close(&self, id: &str) -> Value { + let response = { + let mut state = self.lock(); + let Some(entry) = state.entries.remove(id) else { + return json!({"closed": false}); + }; + state.insertion_order.retain(|existing| existing != id); + let (send, receive) = oneshot::channel(); + let _ = entry.commands.send(Command::Close { response: send }); + receive + }; + let _ = response.await; + json!({"closed": true}) + } + + pub async fn close_all(&self) { + let ids = { + let state = self.lock(); + state + .insertion_order + .iter() + .filter(|id| state.entries.contains_key(*id)) + .cloned() + .collect::<Vec<_>>() + }; + futures::future::join_all(ids.iter().map(|id| self.close(id))).await; + } + + pub fn list(&self, type_filter: Option<&Value>) -> Value { + let now = now_ms(); + let state = self.lock(); + let sessions = state + .insertion_order + .iter() + .filter_map(|id| state.entries.get(id)) + .filter(|entry| match type_filter { + None => true, + Some(Value::String(wanted)) => entry.session_type.as_str() == wanted, + Some(_) => false, + }) + .map(|entry| entry.info(now)) + .collect::<Vec<_>>(); + json!({"sessions": sessions}) + } + + pub fn sweep_idle(&self) -> Vec<String> { + let idle_ms = self.idle_timeout_ms; + if idle_ms == 0 { + return Vec::new(); + } + let cutoff = now_ms().saturating_sub(i64::try_from(idle_ms).unwrap_or(i64::MAX)); + let mut state = self.lock(); + let stale = state + .insertion_order + .iter() + .filter_map(|id| state.entries.get(id).map(|entry| (id, entry))) + .filter(|(_, entry)| entry.last_used_ms.load(Ordering::Relaxed) < cutoff) + .map(|(id, _)| id.clone()) + .collect::<Vec<_>>(); + for id in &stale { + if let Some(entry) = state.entries.remove(id) { + state.insertion_order.retain(|existing| existing != id); + let (response, _) = oneshot::channel(); + let _ = entry.commands.send(Command::Close { response }); + } + } + stale + } +} + +async fn run_actor(entry: Arc<Entry>, mut receiver: mpsc::UnboundedReceiver<Command>) { + while let Some(command) = receiver.recv().await { + match command { + Command::Run { job, response } => { + entry.last_used_ms.store(now_ms(), Ordering::Relaxed); + // Jobs are now bounded from below (fetch timeouts clamped to + // max_timeout_ms, every CDP command has a hard ceiling), so a + // job can no longer hang forever and wedge the Close queued + // behind it — the root cause the FIFO actor used to expose. + let result = job.await; + entry.last_used_ms.store(now_ms(), Ordering::Relaxed); + let _ = response.send(result); + } + Command::Close { response } => { + if let Backend::Browser(backend) = &entry.backend { + // Bounded: a wedged CDP connection must not make Close + // (or worker shutdown) wait forever; dropping the client + // kills the Chromium child regardless. + let _ = tokio::time::timeout( + std::time::Duration::from_secs(10), + backend.browser.shutdown(), + ) + .await; + } + drop(receiver); + drop(entry); + let _ = response.send(()); + return; + } + } + } +} + +const HTTP_CONSTRUCTOR_KEYS: &[&str] = &[ + "impersonate", + "http3", + "stealthy_headers", + "proxy", + "proxies", + "proxy_auth", + "timeout", + "headers", + "retries", + "retry_delay", + "follow_redirects", + "max_redirects", + "verify", +]; + +const BROWSER_CONSTRUCTOR_KEYS: &[&str] = &[ + "headless", + "network_idle", + "load_dom", + "timeout", + "wait", + "wait_selector", + "wait_selector_state", + "disable_resources", + "proxy", + "useragent", + "cookies", + "google_search", + "block_ads", + "blocked_domains", + "real_chrome", + "cdp_url", + "capture_xhr", + "locale", + "timezone_id", + "extra_headers", + "dns_over_https", + "retries", + "retry_delay", + "extra_flags", + "max_pages", + "solve_cloudflare", + "block_webrtc", + "hide_canvas", + "allow_webgl", +]; + +const BROWSER_FETCH_KEYS: &[&str] = &[ + "google_search", + "timeout", + "wait", + "wait_selector", + "wait_selector_state", + "disable_resources", + "extra_headers", + "network_idle", + "load_dom", + "blocked_domains", + "solve_cloudflare", + "proxy", +]; + +fn constructor_config(payload: &Value) -> Value { + selected_config(payload, HTTP_CONSTRUCTOR_KEYS) +} + +fn browser_constructor_config(payload: &Value) -> Value { + selected_config(payload, BROWSER_CONSTRUCTOR_KEYS) +} + +fn selected_config(payload: &Value, keys: &[&str]) -> Value { + let mut result = Map::new(); + if let Some(values) = payload.as_object() { + for &key in keys { + if let Some(value) = values.get(key).filter(|value| !value.is_null()) { + result.insert(key.to_string(), value.clone()); + } + } + } + Value::Object(result) +} + +fn merge_request(constructor: &Value, payload: &Value) -> Value { + let mut result = constructor.as_object().cloned().unwrap_or_default(); + if let Some(values) = payload.as_object() { + for (key, value) in values { + if key != "session_id" && key != "type" && !value.is_null() { + result.insert(key.clone(), value.clone()); + } + } + } + Value::Object(result) +} + +fn merge_browser_request(constructor: &Value, payload: &Value) -> Value { + let mut result = constructor.as_object().cloned().unwrap_or_default(); + if let Some(values) = payload.as_object() { + for (key, value) in values { + if key != "session_id" + && key != "type" + && !value.is_null() + && !(key == "proxy" && value.as_str() == Some("")) + && (!BROWSER_CONSTRUCTOR_KEYS.contains(&key.as_str()) + || BROWSER_FETCH_KEYS.contains(&key.as_str())) + { + result.insert(key.clone(), value.clone()); + } + } + if values + .get("headers") + .and_then(Value::as_object) + .is_some_and(|headers| !headers.is_empty()) + && !values.contains_key("extra_headers") + { + result.insert("extra_headers".to_string(), values["headers"].clone()); + } + } + Value::Object(result) +} + +/// Every option that safe mode refuses somewhere must be listed here, or a +/// session opens fine and only errors (or is silently ignored) at first +/// fetch. Keep in sync with the safe-mode rejects in +/// `HttpOptions::from_payload_for_mode` (HTTP tier) and +/// `RawBrowserOptions::validate_policy` (browser tier). +pub fn uses_compat_only_options(payload: &Value) -> bool { + payload.get("verify") == Some(&Value::Bool(false)) + || ["proxy", "cdp_url"].iter().any(|key| { + payload + .get(key) + .and_then(Value::as_str) + .is_some_and(|v| !v.is_empty()) + }) + || payload.get("proxy_auth").is_some_and(|v| !v.is_null()) + || payload + .get("proxies") + .and_then(Value::as_object) + .is_some_and(|v| !v.is_empty()) + || payload + .get("extra_flags") + .and_then(Value::as_array) + .is_some_and(|v| !v.is_empty()) + || payload.get("real_chrome") == Some(&Value::Bool(true)) + || payload.get("dns_over_https") == Some(&Value::Bool(true)) + || payload.get("stealthy_headers") == Some(&Value::Bool(true)) + || payload.get("http3") == Some(&Value::Bool(true)) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; + use std::time::Duration; + + use super::*; + + fn id(response: &Value) -> &str { + response["session_id"].as_str().unwrap() + } + + #[tokio::test] + async fn ids_are_uuid4_lowercase_hex_and_interactive_ids_are_foreign() { + let registry = Registry::new(8, 900); + let opened = registry + .open_http(&json!({}), false, HttpMode::Safe) + .await + .unwrap(); + let sid = id(&opened); + assert_eq!(sid.len(), 32); + assert!(sid + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))); + assert_eq!(&sid[12..13], "4"); + assert_eq!( + registry + .run("b1", Box::pin(async { Ok(Value::Null) })) + .await + .unwrap_err(), + "unknown session: b1" + ); + } + + #[tokio::test] + async fn active_plus_pending_reserves_capacity_and_rolls_back_failure() { + let registry = Arc::new(Registry::new(1, 900)); + let gate = Arc::new(tokio::sync::Notify::new()); + let opening = { + let registry = registry.clone(); + let gate = gate.clone(); + tokio::spawn(async move { + registry + .open_with(SessionType::Http, false, async move { + gate.notified().await; + Err("constructor failed".to_string()) + }) + .await + }) + }; + tokio::task::yield_now().await; + assert_eq!( + registry + .open_http(&json!({}), false, HttpMode::Safe) + .await + .unwrap_err(), + "session limit reached (1); close one first" + ); + gate.notify_one(); + assert_eq!(opening.await.unwrap().unwrap_err(), "constructor failed"); + registry + .open_http(&json!({}), false, HttpMode::Safe) + .await + .unwrap(); + } + + #[tokio::test] + async fn same_session_is_fifo_close_queues_and_different_sessions_overlap() { + let registry = Arc::new(Registry::new(2, 900)); + let first = registry + .open_http(&json!({}), false, HttpMode::Safe) + .await + .unwrap(); + let second = registry + .open_http(&json!({}), false, HttpMode::Safe) + .await + .unwrap(); + let active = Arc::new(AtomicUsize::new(0)); + let peak = Arc::new(AtomicUsize::new(0)); + let order = Arc::new(Mutex::new(Vec::new())); + let run = |sid: String, marker: usize| { + let registry = registry.clone(); + let active = active.clone(); + let peak = peak.clone(); + let order = order.clone(); + tokio::spawn(async move { + registry + .run( + &sid, + Box::pin(async move { + let now = active.fetch_add(1, AtomicOrdering::SeqCst) + 1; + peak.fetch_max(now, AtomicOrdering::SeqCst); + order.lock().unwrap().push(marker); + tokio::time::sleep(Duration::from_millis(20)).await; + active.fetch_sub(1, AtomicOrdering::SeqCst); + Ok(json!(marker)) + }), + ) + .await + }) + }; + let a = run(id(&first).to_string(), 1); + tokio::task::yield_now().await; + let b = run(id(&first).to_string(), 2); + let c = run(id(&second).to_string(), 3); + tokio::task::yield_now().await; + let close = { + let registry = registry.clone(); + let sid = id(&first).to_string(); + tokio::spawn(async move { registry.close(&sid).await }) + }; + assert_eq!(a.await.unwrap().unwrap(), json!(1)); + assert_eq!(b.await.unwrap().unwrap(), json!(2)); + assert_eq!(c.await.unwrap().unwrap(), json!(3)); + assert_eq!(close.await.unwrap(), json!({"closed": true})); + assert!(peak.load(AtomicOrdering::SeqCst) >= 2); + let order = order.lock().unwrap(); + assert!(order.iter().position(|v| *v == 1) < order.iter().position(|v| *v == 2)); + } + + #[tokio::test] + async fn list_preserves_insertion_order_and_exact_type_filter() { + let registry = Registry::new(3, 900); + let http = registry + .open_http(&json!({}), false, HttpMode::Safe) + .await + .unwrap(); + let second = registry + .open_http(&json!({}), false, HttpMode::Safe) + .await + .unwrap(); + let listed = registry.list(None); + assert_eq!(listed["sessions"][0]["session_id"], http["session_id"]); + assert_eq!(listed["sessions"][1]["session_id"], second["session_id"]); + assert_eq!( + registry.list(Some(&json!("stealthy"))), + json!({"sessions": []}) + ); + assert_eq!(registry.list(Some(&json!(3))), json!({"sessions": []})); + } + + #[tokio::test] + async fn private_browser_backend_opens_lists_and_closes() { + let executable = std::env::var_os("SCRAPLING_CHROMIUM_EXECUTABLE") + .map(std::path::PathBuf::from) + .or_else(|| crate::functions::doctor::detect_chromium(&WorkerConfig::default())); + let Some(executable) = executable.filter(|executable| { + crate::functions::doctor::chromium_version(executable).is_some_and(|version| { + version + .split_whitespace() + .any(|part| part == "148.0.7778.96") + }) + }) else { + #[cfg(feature = "scrapling-compat")] + panic!("certified tests require SCRAPLING_CHROMIUM_EXECUTABLE=Chrome-148"); + #[cfg(not(feature = "scrapling-compat"))] + return; + }; + let mut config = WorkerConfig::default(); + config.scrapling.chromium_executable = executable.display().to_string(); + let registry = Registry::new(2, 900); + let http = registry + .open_http(&json!({}), false, HttpMode::Safe) + .await + .unwrap(); + let opened = registry + .open_browser( + SessionType::Dynamic, + &json!({"headless": true}), + false, + Arc::new(config), + ) + .await + .unwrap(); + let sid = id(&opened).to_string(); + assert_eq!(opened["type"], "dynamic"); + assert_eq!( + registry.list(None)["sessions"][0]["session_id"], + http["session_id"] + ); + assert_eq!(registry.list(None)["sessions"][1]["session_id"], sid); + assert_eq!( + registry.list(Some(&json!("dynamic")))["sessions"][0]["session_id"], + sid + ); + assert_eq!( + registry + .open_http(&json!({}), false, HttpMode::Safe) + .await + .unwrap_err(), + "session limit reached (2); close one first" + ); + assert_eq!(registry.close(&sid).await, json!({"closed": true})); + assert_eq!(registry.close(id(&http)).await, json!({"closed": true})); + } + + #[test] + fn constructor_state_is_allowlisted_and_null_request_values_do_not_override() { + let backend = + HttpBackend::new(&json!({"type":"http", "timeout":5, "json":{"no":"constructor"}})); + let merged = backend.request(&json!({"session_id":"id", "timeout":null, "url":"u"})); + assert_eq!(merged, json!({"timeout":5, "url":"u"})); + } + + #[test] + fn browser_fetch_overrides_only_request_state() { + let constructor = browser_constructor_config(&json!({ + "useragent": "constructor-agent", + "wait_selector": "#constructor", + "extra_headers": {"x-constructor": "yes"}, + "cookies": [{"name": "a", "value": "b"}] + })); + let merged = merge_browser_request( + &constructor, + &json!({ + "session_id": "id", + "url": "https://example.test", + "useragent": "ignored-request-agent", + "cookies": [], + "wait_selector": "#request", + "headers": {"authorization": "Bearer token"}, + "proxy": "http://request-proxy:8080" + }), + ); + assert_eq!(merged["useragent"], "constructor-agent"); + assert_eq!(merged["cookies"], json!([{"name": "a", "value": "b"}])); + assert_eq!(merged["wait_selector"], "#request"); + assert_eq!( + merged["extra_headers"], + json!({"authorization": "Bearer token"}) + ); + assert_eq!(merged["proxy"], "http://request-proxy:8080"); + + let constructor = browser_constructor_config(&json!({ + "proxy": "http://constructor-proxy:8080" + })); + assert_eq!( + merge_browser_request(&constructor, &json!({"proxy": ""}))["proxy"], + "http://constructor-proxy:8080" + ); + } + + #[tokio::test] + async fn idle_reap_removes_metadata_and_backend_together() { + let registry = Registry::new(1, 1); + let opened = registry + .open_http(&json!({}), false, HttpMode::Safe) + .await + .unwrap(); + let sid = id(&opened).to_string(); + registry + .lock() + .entries + .get(&sid) + .unwrap() + .last_used_ms + .store(now_ms() - 2_000, Ordering::Relaxed); + assert_eq!(registry.sweep_idle(), std::slice::from_ref(&sid)); + assert_eq!(registry.list(None), json!({"sessions": []})); + assert_eq!( + registry.http_backend(&sid).unwrap_err(), + format!("unknown session: {sid}") + ); + } + + #[tokio::test] + async fn compat_metadata_and_close_are_exact_and_idempotent() { + let registry = Registry::new(1, 900); + let opened = registry + .open_http(&json!({"verify": false}), true, HttpMode::Safe) + .await + .unwrap(); + let sid = id(&opened).to_string(); + assert!(registry.uses_compat_only_options(&sid).unwrap()); + assert_eq!(registry.close(&sid).await, json!({"closed": true})); + assert_eq!(registry.close(&sid).await, json!({"closed": false})); + } + + #[cfg(feature = "scrapling-compat")] + #[tokio::test] + async fn compat_http_transport_is_snapshotted_at_open() { + let registry = Registry::new(1, 900); + let opened = registry + .open_http(&json!({}), true, HttpMode::Compat) + .await + .unwrap(); + let sid = id(&opened); + let backend = registry.http_backend(sid).unwrap(); + assert_eq!(backend.mode, HttpMode::Compat); + assert!(backend.compat.is_some()); + assert_eq!(registry.close(sid).await, json!({"closed": true})); + } + + #[test] + fn response_and_error_text_match_the_frozen_wrapper() { + assert_eq!(parse_type(&json!({})).unwrap(), SessionType::Http); + assert_eq!( + parse_type(&json!({"type":"stealthy"})).unwrap(), + SessionType::Stealthy + ); + assert_eq!( + parse_type(&json!({"type":"carrier-pigeon"})).unwrap_err(), + "unknown session type: 'carrier-pigeon' (use http|dynamic|stealthy)" + ); + assert_eq!( + parse_type(&json!({"type":null})).unwrap_err(), + "unknown session type: None (use http|dynamic|stealthy)" + ); + } + + #[test] + fn every_unsafe_constructor_shape_marks_the_session_compat_only() { + for payload in [ + json!({"verify": false}), + json!({"proxy": "http://proxy"}), + json!({"proxies": {"https": "http://proxy"}}), + json!({"proxy_auth": ["user", "pass"]}), + json!({"cdp_url": "ws://browser"}), + json!({"extra_flags": ["--no-sandbox"]}), + json!({"real_chrome": true}), + ] { + assert!(uses_compat_only_options(&payload), "{payload}"); + } + assert!(!uses_compat_only_options(&json!({"extra_flags": []}))); + } +} diff --git a/browser/src/scrapling/similar.rs b/browser/src/scrapling/similar.rs new file mode 100644 index 000000000..df2c3ce88 --- /dev/null +++ b/browser/src/scrapling/similar.rs @@ -0,0 +1,419 @@ +//! `find_similar` — parser.py's structural auto-match (`Selector.find_similar` +//! together with `__are_alike`) — and the `ratio`/`round2` numeric primitives +//! it's built on (difflib `SequenceMatcher.ratio()` + CPython `round(x, 2)`). + +use std::collections::HashMap; + +use crate::scrapling::{dom, dom::ElementRef, text}; + +/// `find_similar`'s `ignore_attributes` default — fixed here, not exposed as +/// a parameter (ledger: attrs minus href/src, "not exposed"). +const IGNORE_ATTRS: [&str; 2] = ["href", "src"]; + +/// A matching block in the same coordinate order as Python's +/// `difflib.Match(a, b, size)`. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct Match { + first_start: usize, + second_start: usize, + size: usize, +} + +impl Match { + fn new(first_start: usize, second_start: usize, size: usize) -> Self { + Self { + first_start, + second_start, + size, + } + } +} + +/// The character-sequence subset of CPython 3.12's `SequenceMatcher` used by +/// Scrapling. There is no junk predicate in the wrapper call, but the default +/// autojunk heuristic is observable for inputs of 200 characters or more. +struct SequenceMatcher<'a> { + first: &'a [char], + second: &'a [char], + second_indices: HashMap<char, Vec<usize>>, +} + +impl<'a> SequenceMatcher<'a> { + fn new(first: &'a [char], second: &'a [char]) -> Self { + Self::with_autojunk(first, second, true) + } + + fn with_autojunk(first: &'a [char], second: &'a [char], autojunk: bool) -> Self { + let mut second_indices: HashMap<char, Vec<usize>> = HashMap::new(); + for (index, &value) in second.iter().enumerate() { + second_indices.entry(value).or_default().push(index); + } + + // CPython's heuristic removes values occurring more than 1% + 1 + // times, but only once the second sequence reaches 200 items. + if autojunk && second.len() >= 200 { + let popularity_cutoff = second.len() / 100 + 1; + second_indices.retain(|_, indices| indices.len() <= popularity_cutoff); + } + + Self { + first, + second, + second_indices, + } + } + + fn find_longest_match( + &self, + first_start: usize, + first_end: usize, + second_start: usize, + second_end: usize, + ) -> Match { + let (mut best_first, mut best_second, mut best_size) = (first_start, second_start, 0usize); + let mut previous_lengths = HashMap::<usize, usize>::new(); + + for first_index in first_start..first_end { + let mut current_lengths = HashMap::<usize, usize>::new(); + if let Some(second_positions) = self.second_indices.get(&self.first[first_index]) { + for &second_index in second_positions { + if second_index < second_start { + continue; + } + if second_index >= second_end { + break; + } + + let size = second_index + .checked_sub(1) + .and_then(|previous| previous_lengths.get(&previous).copied()) + .unwrap_or(0) + + 1; + current_lengths.insert(second_index, size); + // Strictly greater preserves CPython's earliest-first tie + // breaking as both sequences are scanned in order. + if size > best_size { + best_first = first_index + 1 - size; + best_second = second_index + 1 - size; + best_size = size; + } + } + } + previous_lengths = current_lengths; + } + + // Popular elements are absent from second_indices but are not junk, + // so CPython extends a seeded match across them in either direction. + while best_first > first_start + && best_second > second_start + && self.first[best_first - 1] == self.second[best_second - 1] + { + best_first -= 1; + best_second -= 1; + best_size += 1; + } + while best_first + best_size < first_end + && best_second + best_size < second_end + && self.first[best_first + best_size] == self.second[best_second + best_size] + { + best_size += 1; + } + + Match::new(best_first, best_second, best_size) + } + + fn get_matching_blocks(&self) -> Vec<Match> { + let mut queue = vec![(0, self.first.len(), 0, self.second.len())]; + let mut blocks = Vec::new(); + + while let Some((first_start, first_end, second_start, second_end)) = queue.pop() { + let matched = self.find_longest_match(first_start, first_end, second_start, second_end); + if matched.size == 0 { + continue; + } + + if first_start < matched.first_start && second_start < matched.second_start { + queue.push(( + first_start, + matched.first_start, + second_start, + matched.second_start, + )); + } + if matched.first_start + matched.size < first_end + && matched.second_start + matched.size < second_end + { + queue.push(( + matched.first_start + matched.size, + first_end, + matched.second_start + matched.size, + second_end, + )); + } + blocks.push(matched); + } + + blocks.sort(); + + // Collapse adjacent blocks exactly as CPython does, then append its + // terminal zero-sized sentinel. + let mut collapsed = Vec::with_capacity(blocks.len() + 1); + let (mut first_start, mut second_start, mut size) = (0, 0, 0); + for block in blocks { + if first_start + size == block.first_start && second_start + size == block.second_start + { + size += block.size; + } else { + if size != 0 { + collapsed.push(Match::new(first_start, second_start, size)); + } + first_start = block.first_start; + second_start = block.second_start; + size = block.size; + } + } + if size != 0 { + collapsed.push(Match::new(first_start, second_start, size)); + } + collapsed.push(Match::new(self.first.len(), self.second.len(), 0)); + collapsed + } + + fn ratio(&self) -> f64 { + let total = self.first.len() + self.second.len(); + if total == 0 { + return 1.0; + } + let matches: usize = self + .get_matching_blocks() + .into_iter() + .map(|block| block.size) + .sum(); + 2.0 * matches as f64 / total as f64 + } +} + +/// `difflib.SequenceMatcher(None, a, b).ratio()` over chars, computed directly +/// in f64 as `2.0 * matches / (len_a + len_b)`. +pub fn ratio(a: &str, b: &str) -> f64 { + let a_chars: Vec<char> = a.chars().collect(); + let b_chars: Vec<char> = b.chars().collect(); + SequenceMatcher::new(&a_chars, &b_chars).ratio() +} + +/// CPython `round(x, 2)`. Rust's `{:.2}` formatter and CPython's `round` are +/// both correctly-rounded half-even decimal conversions of the same +/// underlying binary64 value, so this agrees with the oracle bit-for-bit. +pub fn round2(x: f64) -> f64 { + format!("{:.2}", x).parse().unwrap() +} + +/// Number of ancestor elements (lxml `len(list(el.iterancestors()))`). +fn depth(el: ElementRef) -> usize { + let mut n = 0; + let mut cur = el; + while let Some(parent) = dom::parent_element(cur) { + n += 1; + cur = parent; + } + n +} + +/// Attrs minus the fixed ignore list, source order preserved — order matters +/// here because `are_alike` sums `ratio()` calls over these in iteration +/// order, and that sum must match Python's dict-iteration order bit-for-bit. +fn filtered_attrs<'a>(el: ElementRef<'a>) -> Vec<(&'a str, &'a str)> { + el.attrs() + .filter(|(name, _)| !IGNORE_ATTRS.contains(name)) + .collect() +} + +/// Same tag + parent-tag + grandparent-tag chain as the anchor's precomputed +/// `parent_tag`/`grandparent_tag` (parser.py builds `//{grandparent}/{parent}/{tag}` +/// and evaluates it as an absolute, whole-document XPath; walked directly +/// here instead of through the xpath engine, per the ledger). +fn chain_matches(el: ElementRef, parent_tag: Option<&str>, grandparent_tag: Option<&str>) -> bool { + let Some(want_parent) = parent_tag else { + return true; + }; + let Some(parent) = dom::parent_element(el) else { + return false; + }; + if parent.name() != want_parent { + return false; + } + let Some(want_grandparent) = grandparent_tag else { + return true; + }; + dom::parent_element(parent).is_some_and(|gp| gp.name() == want_grandparent) +} + +/// `__are_alike`: accept iff `checks > 0 && round2(score / checks) >= threshold`. +fn are_alike( + anchor: ElementRef, + target_attrs: &[(&str, &str)], + candidate: ElementRef, + threshold: f64, + match_text: bool, +) -> bool { + let candidate_attrs = filtered_attrs(candidate); + let mut score = 0.0_f64; + let mut checks = 0usize; + + if !target_attrs.is_empty() { + for &(k, v) in target_attrs { + let cv = candidate_attrs + .iter() + .copied() + .find(|&(ck, _)| ck == k) + .map_or("", |(_, cv)| cv); + score += ratio(v, cv); + } + // `max`, not `min`: extra candidate attrs are penalized, and fewer + // candidate attrs don't inflate the score via a smaller denominator. + checks += target_attrs.len().max(candidate_attrs.len()); + } else if candidate_attrs.is_empty() { + // Both attribute-free: "this must mean something" (parser.py comment). + score += 1.0; + checks += 1; + } + + if match_text { + // `__are_alike` uses `clean_spaces` (core/utils/_utils.py), NOT + // `TextHandler.clean` — a different cleaner: `\n`/`\r` are deleted + // outright (no space inserted) and the result is never trimmed. + let a_text = text::clean_spaces(&dom::leading_text(anchor)); + let b_text = text::clean_spaces(&dom::leading_text(candidate)); + score += ratio(&a_text, &b_text); + checks += 1; + } + + checks > 0 && round2(score / checks as f64) >= threshold +} + +/// Elements at the anchor's own ancestor depth, sharing its tag/parent-tag/ +/// grandparent-tag chain, scored alike by `__are_alike` — document order, +/// anchor excluded, no cap. +pub fn find_similar<'a>( + doc: &'a dom::Doc, + anchor: ElementRef<'a>, + threshold: f64, + match_text: bool, +) -> Vec<ElementRef<'a>> { + let anchor_depth = depth(anchor); + let anchor_tag = anchor.name(); + let anchor_parent = dom::parent_element(anchor); + let parent_tag = anchor_parent.map(ElementRef::name); + let grandparent_tag = anchor_parent + .and_then(dom::parent_element) + .map(ElementRef::name); + let target_attrs = filtered_attrs(anchor); + + dom::descendant_elements(doc.root()) + .into_iter() + .filter(|&el| el.id() != anchor.id()) + // ponytail: depth() walk runs before the cheaper tag check; reorder + // if find-similar ever profiles hot. + .filter(|&el| depth(el) == anchor_depth) + .filter(|&el| el.name() == anchor_tag) + .filter(|&el| chain_matches(el, parent_tag, grandparent_tag)) + .filter(|&el| are_alike(anchor, &target_attrs, el, threshold, match_text)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ratio_matches_cpython_examples() { + // difflib.SequenceMatcher(None,'card wide','card slim').ratio() == 0.6666666666666666 + assert_eq!(ratio("card wide", "card slim"), 0.666_666_666_666_666_6); + assert_eq!(ratio("", ""), 1.0); + assert_eq!(ratio("abc", "abc"), 1.0); + } + + #[test] + fn ratio_ignores_popular_elements_at_cpython_autojunk_threshold() { + // Frozen CPython 3.12.13: with len(b) == 200, the 199 `x` values + // are popular and removed from b2j. Since the leading values differ, + // there is no non-popular match from which the run can be extended. + let a = "x".repeat(199); + let b = format!("y{}", "x".repeat(199)); + assert_eq!(ratio(&a, &b), 0.0); + } + + #[test] + fn autojunk_stays_disabled_below_two_hundred_items() { + // Frozen CPython 3.12.13: len(b) == 199 does not activate autojunk. + let a = "x".repeat(198); + let b = format!("y{}", "x".repeat(198)); + assert_eq!(ratio(&a, &b), 0.997_481_108_312_342_6); + } + + #[test] + fn matching_blocks_follow_cpython_order_and_include_terminal_sentinel() { + // Frozen CPython 3.12.13: + // SequenceMatcher(None, "abxcd", "abcd").get_matching_blocks() + let a: Vec<char> = "abxcd".chars().collect(); + let b: Vec<char> = "abcd".chars().collect(); + let matcher = SequenceMatcher::new(&a, &b); + let blocks: Vec<_> = matcher + .get_matching_blocks() + .iter() + .map(|m| (m.first_start, m.second_start, m.size)) + .collect(); + assert_eq!(blocks, [(0, 0, 2), (3, 2, 2), (5, 4, 0)]); + } + + #[test] + fn equal_length_ties_prefer_the_earliest_first_sequence_position() { + // Frozen CPython 3.12.13: the competing one-character matches are + // resolved to a[0] before considering the earlier position in b. + let a: Vec<char> = "ab".chars().collect(); + let b: Vec<char> = "ba".chars().collect(); + let matcher = SequenceMatcher::new(&a, &b); + assert_eq!( + matcher.get_matching_blocks(), + [Match::new(0, 1, 1), Match::new(2, 2, 0)] + ); + } + + #[test] + fn popular_elements_extend_a_match_seeded_by_a_rare_element() { + // Frozen CPython 3.12.13: `x` is popular, but it remains eligible for + // extension around the non-popular `z` seed. + let value = format!("{}z{}", "x".repeat(100), "x".repeat(100)); + let chars: Vec<char> = value.chars().collect(); + let matcher = SequenceMatcher::new(&chars, &chars); + assert_eq!( + matcher.get_matching_blocks(), + [Match::new(0, 0, 201), Match::new(201, 201, 0)] + ); + assert_eq!(matcher.ratio(), 1.0); + } + + #[test] + fn disabling_autojunk_retains_repeated_matches() { + // Frozen CPython 3.12.13 with autojunk=False. + let a: Vec<char> = "x".repeat(199).chars().collect(); + let b: Vec<char> = format!("y{}", "x".repeat(199)).chars().collect(); + let matcher = SequenceMatcher::with_autojunk(&a, &b, false); + let blocks: Vec<_> = matcher + .get_matching_blocks() + .iter() + .map(|m| (m.first_start, m.second_start, m.size)) + .collect(); + assert_eq!(blocks, [(0, 1, 199), (199, 200, 0)]); + assert_eq!(matcher.ratio(), 0.997_493_734_335_839_5); + } + + #[test] + fn round2_is_half_even_decimal() { + assert_eq!(round2(0.665), 0.67); // 0.665 is 0.66500000000000003xx in binary + assert_eq!(round2(0.5), 0.5); + assert_eq!(round2(2.0 / 3.0), 0.67); + assert_eq!(round2(0.625), 0.62); + assert_eq!(round2(0.635), 0.64); + } +} diff --git a/browser/src/scrapling/text.rs b/browser/src/scrapling/text.rs new file mode 100644 index 000000000..ec7833da7 --- /dev/null +++ b/browser/src/scrapling/text.rs @@ -0,0 +1,358 @@ +//! Python-string semantics: scrapling's TextHandler.clean()/.re() ports. + +use rustpython_sre_engine::{compiler, Request, SearchIter, State, StrDrive}; + +pub fn clean(s: &str) -> String { + let translated: String = s + .chars() + .map(|c| { + if matches!(c, '\t' | '\r' | '\n') { + ' ' + } else { + c + } + }) + .collect(); + collapse_spaces(&translated).trim().to_string() +} + +/// scrapling `clean_spaces` (`scrapling/core/utils/_utils.py`: +/// `__CLEANING_TABLE__ = str.maketrans({"\t": " ", "\n": None, "\r": None})` +/// then `__CONSECUTIVE_SPACES_REGEX__.sub(" ", string)`). Unlike `clean` +/// above: `\n`/`\r` are DELETED outright (not turned into a space — so they +/// can glue two words together with no separator at all), and there is no +/// trim. Used by `similar`'s `match_text` scoring (`__are_alike`); `clean` +/// stays the one `find_by_text`/`find_by_regex` use — this is a genuinely +/// different function in scrapling, not a duplicate. +pub fn clean_spaces(s: &str) -> String { + let translated: String = s + .chars() + .filter_map(|c| match c { + '\t' => Some(' '), + '\n' | '\r' => None, + other => Some(other), + }) + .collect(); + collapse_spaces(&translated) +} + +/// Runs of the ASCII space character collapse to one (shared by `clean` and +/// `clean_spaces`; the only difference between them is the translate step +/// and whether the result gets trimmed afterward). +fn collapse_spaces(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut prev_space = false; + for c in s.chars() { + if c == ' ' { + if !prev_space { + out.push(c); + } + prev_space = true; + } else { + out.push(c); + prev_space = false; + } + } + out +} + +/// XPath normalize-space: trim + collapse all whitespace runs to single spaces. +pub fn normalize_space(s: &str) -> String { + s.split([' ', '\t', '\r', '\n']) + .filter(|p| !p.is_empty()) + .collect::<Vec<_>>() + .join(" ") +} + +pub struct PyRegex { + codes: Vec<u32>, + groups: usize, +} + +pub fn compile(pattern: &str, case_insensitive: bool) -> Result<PyRegex, String> { + let compiled = + compiler::compile(pattern, case_insensitive).map_err(|error| error.to_string())?; + Ok(PyRegex { + codes: compiled.codes, + groups: compiled.groups, + }) +} + +impl PyRegex { + pub fn findall(&self, text: &str) -> Vec<String> { + let request = Request::new(text, 0, text.count(), &self.codes, false); + let mut matches = SearchIter { + req: request, + state: State::default(), + }; + let chars: Vec<char> = text.chars().collect(); + let mut out = Vec::new(); + while matches.next().is_some() { + if self.groups == 0 { + out.push( + chars[matches.state.start..matches.state.cursor.position] + .iter() + .collect(), + ); + } else { + for group in 0..self.groups { + let (start, end) = matches.state.marks.get(group); + out.push(match (start.into_option(), end.into_option()) { + (Some(start), Some(end)) => chars[start..end].iter().collect(), + _ => String::new(), + }); + } + } + } + out.into_iter() + .map(|match_| replace_entities(&match_)) + .collect() + } + + pub fn find_first(&self, text: &str) -> Option<String> { + self.findall(text).into_iter().next() + } + + pub fn check_match(&self, text: &str) -> bool { + let request = Request::new(text, 0, text.count(), &self.codes, false); + State::default().search(request) + } +} + +fn replace_entities(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut position = 0; + while let Some(relative) = text[position..].find('&') { + let amp = position + relative; + out.push_str(&text[position..amp]); + let Some((consumed, entity, semicolon)) = parse_entity(&text[amp + 1..]) else { + out.push('&'); + position = amp + 1; + continue; + }; + let whole_end = amp + 1 + consumed; + let replacement: Option<String> = match entity { + Entity::Named(name) if name != "apos" => xmloxide::html::entities::lookup_entity(name) + .or_else(|| { + let lower = name.to_lowercase(); + (lower != "apos") + .then(|| xmloxide::html::entities::lookup_entity(&lower)) + .flatten() + }) + .map(str::to_string), + Entity::Number(number) => codepoint(number).map(|ch| ch.to_string()), + _ => None, + }; + if let Some(replacement) = replacement { + out.push_str(&replacement); + } else if !semicolon { + out.push_str(&text[amp..whole_end]); + } + position = whole_end; + } + out.push_str(&text[position..]); + out +} + +enum Entity<'a> { + Named(&'a str), + Number(Option<u32>), +} + +fn parse_entity(input: &str) -> Option<(usize, Entity<'_>, bool)> { + let mut chars = input.char_indices(); + let (_, first) = chars.next()?; + let (body_start, radix, named) = if first == '#' { + match chars.next() { + Some((_, 'x' | 'X')) => (2, 16, false), + Some(_) => (1, 10, false), + None => return None, + } + } else { + (0, 10, true) + }; + let body = &input[body_start..]; + let mut body_end = 0; + for (offset, ch) in body.char_indices() { + let valid = if named { + ch.is_ascii_alphabetic() + || decimal_digit(ch).is_some() + || matches!(ch, '\u{130}' | '\u{131}' | '\u{17f}' | '\u{212a}') + } else if radix == 16 { + ch.is_ascii_hexdigit() || decimal_digit(ch).is_some() + } else { + decimal_digit(ch).is_some() + }; + if !valid { + break; + } + body_end = offset + ch.len_utf8(); + } + if body_end == 0 { + return None; + } + let semicolon = body[body_end..].starts_with(';'); + let consumed = body_start + body_end + usize::from(semicolon); + let value = &body[..body_end]; + Some(( + consumed, + if named { + Entity::Named(value) + } else { + Entity::Number(parse_number(value, radix)) + }, + semicolon, + )) +} + +fn parse_number(value: &str, radix: u32) -> Option<u32> { + value.chars().try_fold(0u32, |number, ch| { + let digit = decimal_digit(ch).or_else(|| ch.to_digit(radix))?; + (digit < radix) + .then(|| number.checked_mul(radix)?.checked_add(digit)) + .flatten() + }) +} + +fn decimal_digit(ch: char) -> Option<u32> { + const ZEROES: &[u32] = &[ + 0x0030, 0x0660, 0x06f0, 0x07c0, 0x0966, 0x09e6, 0x0a66, 0x0ae6, 0x0b66, 0x0be6, 0x0c66, + 0x0ce6, 0x0d66, 0x0de6, 0x0e50, 0x0ed0, 0x0f20, 0x1040, 0x1090, 0x17e0, 0x1810, 0x1946, + 0x19d0, 0x1a80, 0x1a90, 0x1b50, 0x1bb0, 0x1c40, 0x1c50, 0xa620, 0xa8d0, 0xa900, 0xa9d0, + 0xa9f0, 0xaa50, 0xabf0, 0xff10, 0x104a0, 0x10d30, 0x11066, 0x110f0, 0x11136, 0x111d0, + 0x112f0, 0x11450, 0x114d0, 0x11650, 0x116c0, 0x11730, 0x118e0, 0x11950, 0x11c50, 0x11d50, + 0x11da0, 0x11f50, 0x16a60, 0x16ac0, 0x16b50, 0x1d7ce, 0x1d7d8, 0x1d7e2, 0x1d7ec, 0x1d7f6, + 0x1e140, 0x1e2f0, 0x1e4f0, 0x1e950, 0x1fbf0, + ]; + let codepoint = ch as u32; + let index = ZEROES + .partition_point(|candidate| *candidate <= codepoint) + .checked_sub(1)?; + let zero = ZEROES[index]; + (codepoint - zero < 10).then_some(codepoint - zero) +} + +fn codepoint(number: Option<u32>) -> Option<char> { + const WINDOWS_1252: [Option<char>; 32] = [ + Some('\u{20ac}'), + None, + Some('\u{201a}'), + Some('\u{0192}'), + Some('\u{201e}'), + Some('\u{2026}'), + Some('\u{2020}'), + Some('\u{2021}'), + Some('\u{02c6}'), + Some('\u{2030}'), + Some('\u{0160}'), + Some('\u{2039}'), + Some('\u{0152}'), + None, + Some('\u{017d}'), + None, + None, + Some('\u{2018}'), + Some('\u{2019}'), + Some('\u{201c}'), + Some('\u{201d}'), + Some('\u{2022}'), + Some('\u{2013}'), + Some('\u{2014}'), + Some('\u{02dc}'), + Some('\u{2122}'), + Some('\u{0161}'), + Some('\u{203a}'), + Some('\u{0153}'), + None, + Some('\u{017e}'), + Some('\u{0178}'), + ]; + let number = number?; + if (0x80..=0x9f).contains(&number) { + WINDOWS_1252[(number - 0x80) as usize] + } else { + char::from_u32(number) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clean_translates_and_collapses_spaces_only() { + // \t\r\n each become a space, then RUNS OF SPACES collapse, then trim. + assert_eq!(clean(" a\t\tb\n\nc d "), "a b c d"); + } + + #[test] + fn clean_spaces_deletes_newlines_gluing_words_and_never_trims() { + // Oracle (`scrapling.core.utils.clean_spaces`), reviewer-verified + // counterexamples: \n/\r are DELETED (no space inserted), unlike + // `clean`'s \t/\r/\n -> ' ' translation, and there is no trim at all. + assert_eq!(clean_spaces("a\nb"), "ab"); + assert_eq!(clean_spaces(" a "), " a "); // no trim + assert_eq!(clean_spaces("a\tb"), "a b"); // \t still becomes a space + assert_eq!(clean_spaces("a\r\nb"), "ab"); // \r and \n both deleted + assert_eq!(clean_spaces("a b"), "a b"); // space runs still collapse + assert_eq!(clean_spaces(""), ""); + } + + #[test] + fn normalize_space_collapses_all_whitespace() { + assert_eq!(normalize_space(" a \t b \n "), "a b"); + assert_eq!(normalize_space(" \n\t "), ""); + } + + #[test] + fn findall_without_groups_returns_whole_matches() { + let re = compile(r"\d+", false).unwrap(); + assert_eq!(re.findall("price 42 usd then 99"), vec!["42", "99"]); + } + + #[test] + fn findall_with_one_group_returns_group_values() { + let re = compile(r"price (\d+)", false).unwrap(); + assert_eq!(re.findall("price 42 usd then price 99"), vec!["42", "99"]); + } + + #[test] + fn findall_with_two_groups_flattens_in_order() { + let re = compile(r"(\w+)=(\d+)", false).unwrap(); + assert_eq!(re.findall("a=1 b=2"), vec!["a", "1", "b", "2"]); + } + + #[test] + fn findall_unmatched_group_is_empty_string() { + let re = compile(r"(a)|(b)", false).unwrap(); + assert_eq!(re.findall("ab"), vec!["a", "", "", "b"]); + } + + #[test] + fn results_are_entity_decoded() { + // scrapling passes replace_entities=True: "&amp;co" decodes to "&co" + let re = compile(r"&amp;\w+", false).unwrap(); + assert_eq!(re.findall("Smith &amp;co"), vec!["&co"]); + assert_eq!( + replace_entities("&Copy; &apos; &unknown; &unknown"), + "© &unknown" + ); + assert_eq!(replace_entities("&#128; &#129; &#x80 &#x81"), "€ € &#x81"); + assert_eq!(replace_entities("&#०; &#x9;"), "\0 \t"); + } + + #[test] + fn case_insensitive_flag_and_lookahead_work() { + let re = compile(r"apple(?= pie)", true).unwrap(); + assert!(re.check_match("APPLE PIE".to_lowercase().as_str())); + assert_eq!( + compile(r"HeLLo", true).unwrap().findall("hello"), + vec!["hello"] + ); + } + + #[test] + fn invalid_pattern_is_an_error() { + assert!(compile(r"(", false).is_err()); + } +} diff --git a/browser/src/scrapling/xpath/mod.rs b/browser/src/scrapling/xpath/mod.rs new file mode 100644 index 000000000..8ead9fdda --- /dev/null +++ b/browser/src/scrapling/xpath/mod.rs @@ -0,0 +1,98 @@ +//! XPath 1.0 compatibility wrapper over the repository-owned xmloxide fork. + +use xmloxide::tree::NodeKind; +use xmloxide::xpath::{XPathNode, XPathValue}; + +use crate::scrapling::dom::{Doc, ElementRef}; +use crate::scrapling::query::QueryResult; + +pub fn xpath_query<'a>( + doc: &'a Doc, + scope: Option<ElementRef<'a>>, + expression: &str, +) -> Result<Vec<QueryResult<'a>>, String> { + let context = scope.unwrap_or_else(|| doc.root()); + let value = xmloxide::xpath::evaluate(&doc.tree, context.id(), expression) + .map_err(|_| format!("Invalid XPath selector: {expression}"))?; + match value { + XPathValue::NodeSet(nodes) => Ok(nodes + .into_iter() + .filter_map(|node| query_result(doc, node)) + .collect()), + XPathValue::Boolean(false) => Ok(Vec::new()), + XPathValue::Boolean(true) => Err("'bool' object is not iterable".to_string()), + XPathValue::Number(0.0) => Ok(Vec::new()), + XPathValue::Number(_) => Err("'float' object is not iterable".to_string()), + XPathValue::String(value) if value.is_empty() => Ok(Vec::new()), + XPathValue::String(_) => Err("'str' object has no attribute 'iter'".to_string()), + } +} + +fn query_result<'a>(doc: &'a Doc, node: XPathNode) -> Option<QueryResult<'a>> { + match node { + XPathNode::Attribute { owner, index } => { + let attribute = doc.tree.attributes(owner).get(index as usize)?; + Some(QueryResult::Text { + value: attribute.value.clone(), + parent: doc.element(owner)?, + }) + } + XPathNode::Node(id) => match &doc.tree.node(id).kind { + NodeKind::Element { .. } => doc.element(id).map(QueryResult::Element), + NodeKind::Text { content } | NodeKind::CData { content } => { + let parent = element_parent(doc, id)?; + Some(QueryResult::Text { + value: content.clone(), + parent, + }) + } + _ => None, + }, + } +} + +fn element_parent(doc: &Doc, mut node: xmloxide::NodeId) -> Option<ElementRef<'_>> { + while let Some(parent) = doc.tree.parent(node) { + if let Some(element) = doc.element(parent) { + return Some(element); + } + node = parent; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scrapling::dom; + + fn values(results: Vec<QueryResult<'_>>) -> Vec<String> { + results + .into_iter() + .map(|result| match result { + QueryResult::Element(element) => format!("<{}>", element.name()), + QueryResult::Text { value, .. } => value, + }) + .collect() + } + + #[test] + fn axes_functions_and_scalar_quirks_match_lxml_wrapper() { + let doc = dom::parse("<main id='m'><section id='s'><p>A</p><p>B</p></section></main>"); + assert_eq!( + values(xpath_query(&doc, None, "//p/text()").unwrap()), + ["A", "B"] + ); + assert_eq!( + values(xpath_query(&doc, None, "//p/ancestor::*[1]/@id").unwrap()), + ["s"] + ); + assert!(xpath_query(&doc, None, "boolean(//nope)") + .unwrap() + .is_empty()); + assert_eq!( + xpath_query(&doc, None, "boolean(//p)").unwrap_err(), + "'bool' object is not iterable" + ); + } +} diff --git a/browser/src/ssrf.rs b/browser/src/ssrf.rs new file mode 100644 index 000000000..b59503092 --- /dev/null +++ b/browser/src/ssrf.rs @@ -0,0 +1,400 @@ +//! SSRF defense for the outbound `browser::*` fetchers. Parse + +//! validate the URL, resolve the host, and reject any address in a private / +//! loopback / link-local / multicast / reserved range. The resolve+validate +//! happens once; the socket is pinned to the validated IP (see +//! `scrapling/fetch.rs`) to defeat DNS rebinding (TOCTOU between check and +//! connect). +//! +//! Ported from `web/src/ssrf.rs`, which has carried this logic in production; +//! the only change is `reqwest::Url` -> `url::Url` (browser already depends on +//! `url` directly) and the config-knob name in the loopback hint. Kept as a +//! copy rather than a shared crate: it is ~340 stable lines used by two +//! workers, and a shared crate would cost a workspace member, a CI matrix +//! entry and coordinated releases. Extract it if a third worker needs it. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::sync::OnceLock; + +use ipnet::{Ipv4Net, Ipv6Net}; + +#[derive(Debug, Clone, Copy)] +pub struct SsrfPolicy { + pub allow_loopback: bool, +} + +#[derive(Debug, Clone)] +pub struct ParsedTarget { + pub url: url::Url, + pub hostname: String, + pub port: u16, +} + +/// Parse + validate scheme and host shape. No DNS. +pub fn parse_target(raw: &str) -> Result<ParsedTarget, String> { + let url = url::Url::parse(raw).map_err(|_| "url is not a valid absolute URL".to_string())?; + let scheme = url.scheme(); + if scheme != "http" && scheme != "https" { + return Err(format!( + "scheme not allowed: {scheme}: (only http: and https: are permitted)" + )); + } + let hostname = { + let h = url + .host_str() + .filter(|h| !h.is_empty()) + .ok_or_else(|| "url has no hostname".to_string())?; + // `host_str()` keeps the brackets on an IPv6 literal (e.g. "[::1]"). + // Strip them so downstream code can parse the string as an IpAddr. + h.trim_start_matches('[').trim_end_matches(']').to_string() + }; + let port = url + .port_or_known_default() + .ok_or_else(|| "invalid port".to_string())?; + Ok(ParsedTarget { + url, + hostname, + port, + }) +} + +static V4_BLOCKLIST: OnceLock<Vec<(Ipv4Net, &'static str)>> = OnceLock::new(); +static V6_BLOCKLIST: OnceLock<[(Ipv6Net, &'static str); 4]> = OnceLock::new(); + +fn v4_blocklist() -> &'static [(Ipv4Net, &'static str)] { + V4_BLOCKLIST.get_or_init(|| { + [ + ("0.0.0.0/8", "this-network"), + ("10.0.0.0/8", "private rfc1918"), + ("100.64.0.0/10", "cgnat rfc6598"), + ("127.0.0.0/8", "loopback"), + ("169.254.0.0/16", "link-local (incl. AWS metadata)"), + ("172.16.0.0/12", "private rfc1918"), + ("192.0.0.0/24", "ietf protocol assignments"), + ("192.0.2.0/24", "documentation"), + ("192.168.0.0/16", "private rfc1918"), + ("198.18.0.0/15", "benchmarking"), + ("198.51.100.0/24", "documentation"), + ("203.0.113.0/24", "documentation"), + ("224.0.0.0/4", "multicast"), + ("240.0.0.0/4", "reserved"), + ] + .into_iter() + .map(|(cidr, label)| (cidr.parse::<Ipv4Net>().expect("valid v4 cidr"), label)) + .collect() + }) +} + +fn v6_blocklist() -> &'static [(Ipv6Net, &'static str)] { + V6_BLOCKLIST.get_or_init(|| { + [ + ("fe80::/10".parse().unwrap(), "link-local fe80::/10"), + ("fc00::/7".parse().unwrap(), "unique-local fc00::/7"), + ("ff00::/8".parse().unwrap(), "multicast ff00::/8"), + // Local-use NAT64 (RFC 8215): the v4 embed position is + // operator-chosen, so it can't be validated — fail closed. + ( + "64:ff9b:1::/48".parse().unwrap(), + "local-use nat64 64:ff9b:1::/48", + ), + ] + }) +} + +fn check_ipv4(addr: Ipv4Addr, policy: &SsrfPolicy) -> Option<&'static str> { + for (net, label) in v4_blocklist() { + if *label == "loopback" && policy.allow_loopback { + continue; + } + if net.contains(&addr) { + return Some(label); + } + } + None +} + +/// IPv6 addresses that embed and route to an IPv4 address: NAT64 +/// (64:ff9b::/96), 6to4 (2002::/16) and the deprecated IPv4-compatible +/// ::/96 form. The embedded IPv4 must pass the v4 blocklist, otherwise a +/// NAT64/6to4 gateway delivers straight to a private host the v4 rules +/// forbid. +fn embedded_ipv4(addr: &Ipv6Addr) -> Option<Ipv4Addr> { + let s = addr.segments(); + let last32 = |a: u16, b: u16| Ipv4Addr::new((a >> 8) as u8, a as u8, (b >> 8) as u8, b as u8); + // NAT64 well-known prefix 64:ff9b::/96 (RFC 6052 embeds v4 in the low 32 + // bits at that prefix length). + if s[..6] == [0x64, 0xff9b, 0, 0, 0, 0] { + return Some(last32(s[6], s[7])); + } + // 6to4 2002:AABB:CCDD::/48 embeds v4 in bits 16..48. + if s[0] == 0x2002 { + return Some(last32(s[1], s[2])); + } + // IPv4-compatible ::a.b.c.d (::/96). :: and ::1 are caught earlier. + if s[..6] == [0, 0, 0, 0, 0, 0] { + return Some(last32(s[6], s[7])); + } + None +} + +fn check_ipv6(addr: Ipv6Addr, policy: &SsrfPolicy) -> Option<&'static str> { + // IPv4-mapped (::ffff:a.b.c.d) routes to v4 — delegate to the v4 check. + if let Some(v4) = addr.to_ipv4_mapped() { + return check_ipv4(v4, policy); + } + if addr == Ipv6Addr::LOCALHOST { + return if policy.allow_loopback { + None + } else { + Some("loopback") + }; + } + if addr == Ipv6Addr::UNSPECIFIED { + return Some("unspecified"); + } + if let Some(v4) = embedded_ipv4(&addr) { + if let Some(label) = check_ipv4(v4, policy) { + return Some(label); + } + } + for (net, label) in v6_blocklist() { + if net.contains(&addr) { + return Some(label); + } + } + None +} + +/// Returns `Some(label)` if the address is blocked, `None` if allowed. +pub fn check_ip(addr: IpAddr, policy: &SsrfPolicy) -> Option<&'static str> { + match addr { + IpAddr::V4(v4) => check_ipv4(v4, policy), + IpAddr::V6(v6) => check_ipv6(v6, policy), + } +} + +#[derive(Debug, Clone)] +pub struct ResolvedTarget { + pub address: IpAddr, + pub hostname: String, + pub port: u16, +} + +#[derive(Debug, Clone)] +pub struct SsrfReject { + pub code: &'static str, + pub message: String, +} + +fn loopback_hint(policy: &SsrfPolicy) -> &'static str { + if policy.allow_loopback { + "" + } else { + " (set allow_loopback=true in the browser worker config if loopback is intentional in this environment)" + } +} + +/// Resolve the host (or accept a literal IP), validate EVERY resolved +/// address against the blocklist, and return the address to dial (the +/// first resolved address). If ANY address is blocked, the whole call is +/// refused. Fail-closed: DNS failure / empty result map to `blocked_host`. +pub async fn check_target( + target: &ParsedTarget, + policy: &SsrfPolicy, +) -> Result<ResolvedTarget, SsrfReject> { + let host = &target.hostname; + + // Literal IP short-circuit: skip DNS. Brackets were already stripped + // from IPv6 literals in parse_target. + if let Ok(ip) = host.parse::<IpAddr>() { + if let Some(label) = check_ip(ip, policy) { + let hint = if label == "loopback" { + loopback_hint(policy) + } else { + "" + }; + return Err(SsrfReject { + code: "blocked_host", + message: format!("address {host} is in {label}{hint}"), + }); + } + return Ok(ResolvedTarget { + address: ip, + hostname: host.clone(), + port: target.port, + }); + } + + let resolved: Vec<SocketAddr> = + match tokio::net::lookup_host((host.as_str(), target.port)).await { + Ok(it) => it.collect(), + Err(e) => { + return Err(SsrfReject { + code: "blocked_host", + message: format!("dns lookup failed for {host}: {e}"), + }); + } + }; + if resolved.is_empty() { + return Err(SsrfReject { + code: "blocked_host", + message: format!("dns returned no addresses for {host}"), + }); + } + + for sa in &resolved { + if let Some(label) = check_ip(sa.ip(), policy) { + let hint = if label == "loopback" { + loopback_hint(policy) + } else { + "" + }; + return Err(SsrfReject { + code: "blocked_host", + message: format!( + "{host} resolves to {} ({label}); refusing to dial{hint}", + sa.ip() + ), + }); + } + } + + Ok(ResolvedTarget { + address: resolved[0].ip(), + hostname: host.clone(), + port: target.port, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::IpAddr; + + fn blocked(ip: &str, allow_loopback: bool) -> Option<&'static str> { + check_ip( + ip.parse::<IpAddr>().unwrap(), + &SsrfPolicy { allow_loopback }, + ) + } + + #[test] + fn blocks_private_v4_ranges() { + for ip in [ + "10.0.0.1", + "172.16.5.5", + "192.168.1.1", + "169.254.169.254", + "100.64.0.1", + "0.0.0.0", + "224.0.0.1", + "240.0.0.1", + ] { + assert!(blocked(ip, true).is_some(), "{ip} should be blocked"); + } + } + + #[test] + fn allows_public_v4() { + assert!(blocked("1.1.1.1", true).is_none()); + assert!(blocked("93.184.216.34", true).is_none()); + } + + #[test] + fn loopback_policy_v4_and_v6() { + assert!(blocked("127.0.0.1", true).is_none()); + assert!(blocked("127.0.0.1", false).is_some()); + assert!(blocked("::1", true).is_none()); + assert!(blocked("::1", false).is_some()); + // loopback=true still blocks other private ranges + assert!(blocked("169.254.169.254", true).is_some()); + assert!(blocked("10.0.0.1", true).is_some()); + } + + #[test] + fn blocks_v6_ranges() { + for ip in ["::", "fe80::1", "febf::1", "fc00::1", "fd12::1", "ff02::1"] { + assert!(blocked(ip, true).is_some(), "{ip} should be blocked"); + } + assert!(blocked("2606:4700:4700::1111", true).is_none()); + } + + #[test] + fn blocks_v4_embedding_v6_forms() { + // NAT64 well-known prefix: embedded private v4 blocked, public allowed. + assert!(blocked("64:ff9b::a00:1", true).is_some()); // 10.0.0.1 + assert!(blocked("64:ff9b::a9fe:a9fe", true).is_some()); // 169.254.169.254 + assert!(blocked("64:ff9b::101:101", true).is_none()); // 1.1.1.1 + // Local-use NAT64: fail closed regardless of embed. + assert!(blocked("64:ff9b:1::1", true).is_some()); + // 6to4: embedded v4 validated. + assert!(blocked("2002:a00:1::", true).is_some()); // 10.0.0.1 + assert!(blocked("2002:101:101::", true).is_none()); // 1.1.1.1 + // Deprecated IPv4-compatible ::/96. + assert!(blocked("::a9fe:a9fe", true).is_some()); + } + + #[test] + fn blocks_v4_mapped_v6_metadata() { + // both textual and (parsed) hex forms collapse to 169.254.169.254 + assert!(blocked("::ffff:169.254.169.254", true).is_some()); + assert!(blocked("::ffff:a9fe:a9fe", true).is_some()); + } + + #[test] + fn parse_target_rejects_non_http() { + assert!(parse_target("ftp://example.com/").is_err()); + assert!(parse_target("file:///etc/passwd").is_err()); + assert!(parse_target("not a url").is_err()); + } + + #[test] + fn parse_target_defaults_ports() { + assert_eq!(parse_target("http://example.com/").unwrap().port, 80); + assert_eq!(parse_target("https://example.com/").unwrap().port, 443); + assert_eq!(parse_target("http://example.com:8080/").unwrap().port, 8080); + } + + #[tokio::test] + async fn check_target_literal_v4_public_ok() { + let t = parse_target("http://1.1.1.1/").unwrap(); + let r = check_target( + &t, + &SsrfPolicy { + allow_loopback: true, + }, + ) + .await + .unwrap(); + assert_eq!(r.address, "1.1.1.1".parse::<std::net::IpAddr>().unwrap()); + assert_eq!(r.port, 80); + } + + #[tokio::test] + async fn check_target_literal_v4_private_blocked() { + let t = parse_target("http://169.254.169.254/").unwrap(); + let rej = check_target( + &t, + &SsrfPolicy { + allow_loopback: true, + }, + ) + .await + .unwrap_err(); + assert_eq!(rej.code, "blocked_host"); + } + + #[tokio::test] + async fn check_target_literal_v6_bracket_stripped_and_blocked() { + let t = parse_target("http://[::1]/").unwrap(); + let rej = check_target( + &t, + &SsrfPolicy { + allow_loopback: false, + }, + ) + .await + .unwrap_err(); + assert_eq!(rej.code, "blocked_host"); + assert!(rej.message.contains("allow_loopback")); + } +} diff --git a/browser/tests/adaptive.rs b/browser/tests/adaptive.rs new file mode 100644 index 000000000..e0a139d8d --- /dev/null +++ b/browser/tests/adaptive.rs @@ -0,0 +1,243 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use browser::scrapling::{adaptive, dispatch_op}; +use rusqlite::Connection; +use serde_json::{json, Value}; + +fn call(id: &str, payload: Value) -> Value { + dispatch_op(id, &payload).unwrap() +} + +#[test] +fn adaptive_tracking_matches_the_standalone_wrapper_contract() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = + std::env::temp_dir().join(format!("browser-adaptive-{}-{unique}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let db = dir.join("elements.db"); + adaptive::configure(&db).unwrap(); + + let domain = "https://shop.example.com"; + let v1 = + r#"<html><body><div class="price-box"><span class="amount">$42</span></div></body></html>"#; + let v2 = r#"<html><body><section class="pricing"><span class="new-amount">$42</span></section></body></html>"#; + + assert_eq!( + call( + "browser::css", + json!({"html": v1, "query": "span.amount", "adaptive": true, + "adaptive_domain": domain, "identifier": "css-price"}), + ), + json!({"result": ["$42"]}) + ); + assert_eq!( + call( + "browser::css", + json!({"html": v2, "query": "span.amount", "adaptive": true, + "adaptive_domain": domain, "identifier": "css-price"}), + ), + json!({"result": ["$42"]}) + ); + + assert_eq!( + call( + "browser::xpath", + json!({"html": v1, "query": "//span[@class='amount']", "adaptive": true, + "adaptive_domain": domain, "identifier": "xpath-price"}), + ), + json!({"result": ["$42"]}) + ); + assert_eq!( + call( + "browser::xpath", + json!({"html": v2, "query": "//span[@class='amount']", "adaptive": true, + "adaptive_domain": domain, "identifier": "xpath-price"}), + ), + json!({"result": ["$42"]}) + ); + + let ties = r#"<html><body><section><span class="new">$42</span><span class="new">$42</span></section></body></html>"#; + call( + "browser::css", + json!({"html": v1, "query": "span.amount", "adaptive": true, + "adaptive_domain": domain, "identifier": "ties"}), + ); + assert_eq!( + call( + "browser::css", + json!({"html": ties, "query": "span.amount", "adaptive": true, + "adaptive_domain": domain, "identifier": "ties"}), + ), + json!({"result": ["$42", "$42"]}) + ); + + let grouped = r#"<html><body><p class="a">A</p><p class="b">B</p></body></html>"#; + assert_eq!( + call( + "browser::css", + json!({"html": grouped, "query": "p.b, p.a", "adaptive": true, + "adaptive_domain": domain, "identifier": "grouped"}), + ), + json!({"result": ["B", "A"]}) + ); + + // The wrapper evaluates adaptive comma groups in selector order and uses + // the caller's identifier for every group. Each direct hit overwrites the + // preceding group's identity, so the last group is the persisted one. + assert_eq!( + call( + "browser::css", + json!({"html": "<p class='changed-a'>A</p><p class='changed-b'>B</p>", + "query": "p.b, p.a", "adaptive": true, + "adaptive_domain": domain, "identifier": "grouped"}), + ), + json!({"result": ["A", "A"]}) + ); + + // Direct results always win over a stored identity, and only the first + // direct match is saved. + call( + "browser::css", + json!({"html": "<p class='many'>first</p><p class='many'>second</p>", + "query": "p.many", "adaptive": true, + "adaptive_domain": domain, "identifier": "first-only"}), + ); + let connection = Connection::open(&db).unwrap(); + let first_only: Vec<u8> = connection + .query_row( + "SELECT element_data FROM storage WHERE identifier = 'first-only'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + serde_json::from_slice::<Value>(&first_only).unwrap()["text"], + "first" + ); + drop(connection); + + // `auto_save: null` is false in the Python wrapper, while an omitted + // value defaults true for css/xpath and to `adaptive` for extract. + call( + "browser::css", + json!({"html": v1, "query": "span.amount", "adaptive": true, "auto_save": null, + "adaptive_domain": domain, "identifier": "null-not-saved"}), + ); + assert_eq!( + call( + "browser::css", + json!({"html": v2, "query": "span.amount", "adaptive": true, + "adaptive_domain": domain, "identifier": "null-not-saved"}), + ), + json!({"result": []}) + ); + + call( + "browser::css", + json!({"html": v1, "query": "span.amount", "adaptive": true, "auto_save": false, + "adaptive_domain": domain, "identifier": "not-saved"}), + ); + assert_eq!( + call( + "browser::css", + json!({"html": v2, "query": "span.amount", "adaptive": true, + "adaptive_domain": domain, "identifier": "not-saved"}), + ), + json!({"result": []}) + ); + + let selectors = json!([{"name": "price", "css": "span.amount"}]); + call( + "browser::extract", + json!({"html": v1, "selectors": selectors, "adaptive": true, "adaptive_domain": domain}), + ); + assert_eq!( + call( + "browser::extract", + json!({"html": v2, "selectors": selectors, "adaptive": true, "adaptive_domain": domain}), + ), + json!({"extracted": {"price": "$42"}}) + ); + + call( + "browser::extract", + json!({"html": v1, "selectors": [{"name": "extract-null", "css": "span.amount"}], + "adaptive": true, "auto_save": null, "adaptive_domain": domain}), + ); + assert_eq!( + call( + "browser::extract", + json!({"html": v2, "selectors": [{"name": "extract-null", "css": "span.amount"}], + "adaptive": true, "adaptive_domain": domain}), + ), + json!({"extracted": {"extract-null": null}}) + ); + + // These domains distinguish tld 0.13.2's frozen data from the newer PSL + // bundled by the Rust psl crate. `file.core.windows.net` was a suffix in + // the oracle, while `12chars.dev` was not yet one. + call( + "browser::css", + json!({"html": v1, "query": "span.amount", "adaptive": true, + "adaptive_domain": "https://a.file.core.windows.net", "identifier": "added-rule"}), + ); + assert_eq!( + call( + "browser::css", + json!({"html": v2, "query": "span.amount", "adaptive": true, + "adaptive_domain": "https://b.file.core.windows.net", "identifier": "added-rule"}), + ), + json!({"result": []}) + ); + call( + "browser::css", + json!({"html": v1, "query": "span.amount", "adaptive": true, + "adaptive_domain": "https://a.12chars.dev", "identifier": "removed-rule"}), + ); + assert_eq!( + call( + "browser::css", + json!({"html": v2, "query": "span.amount", "adaptive": true, + "adaptive_domain": "https://b.12chars.dev", "identifier": "removed-rule"}), + ), + json!({"result": ["$42"]}) + ); + + let connection = Connection::open(&db).unwrap(); + assert_eq!( + connection + .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0)) + .unwrap(), + "wal" + ); + assert_eq!( + connection + .query_row( + "SELECT typeof(element_data) FROM storage WHERE identifier = 'css-price'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "blob" + ); + connection + .execute( + "UPDATE storage SET element_data = CAST(element_data AS TEXT) WHERE identifier = 'css-price'", + [], + ) + .unwrap(); + drop(connection); + assert_eq!( + call( + "browser::css", + json!({"html": v2, "query": "span.amount", "adaptive": true, + "adaptive_domain": domain, "identifier": "css-price"}), + ), + json!({"result": ["$42"]}) + ); + + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/browser/tests/behavior.rs b/browser/tests/behavior.rs new file mode 100644 index 000000000..06a473953 --- /dev/null +++ b/browser/tests/behavior.rs @@ -0,0 +1,72 @@ +//! Differential fixtures: replay Python-captured request/response pairs +//! through the Rust ops. Outputs and error text must match exactly; unsupported +//! operations are failures, not skips. + +use serde_json::Value; +use std::fs; +use std::path::PathBuf; + +fn behavior_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/golden/behavior") +} + +#[test] +fn behavior_fixtures_match_reference() { + let mut failures = Vec::new(); + let mut ran = 0; + let mut stack = vec![behavior_root()]; + let mut files = Vec::new(); + while let Some(dir) = stack.pop() { + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + stack.push(p); + } else if p.extension().is_some_and(|e| e == "json") { + files.push(p); + } + } + } + files.sort(); + assert!( + !files.is_empty(), + "no behavior fixtures found — run gen_goldens.py behavior" + ); + for file in files { + let fixture: Value = serde_json::from_str(&fs::read_to_string(&file).unwrap()).unwrap(); + let fid = fixture["function"].as_str().unwrap(); + let case = format!("{fid}/{}", fixture["case"].as_str().unwrap()); + let got = browser::scrapling::dispatch_op(fid, &fixture["request"]); + match (got, fixture.get("ok")) { + (Ok(actual), Some(expected)) => { + ran += 1; + if &actual != expected { + failures.push(format!( + "{case}:\n expected: {expected}\n actual: {actual}" + )); + } + } + (Err(actual_err), None) => { + ran += 1; + let expected_err = fixture["err"].as_str().unwrap(); + if actual_err != expected_err { + failures.push(format!( + "{case} (error text):\n expected: {expected_err}\n actual: {actual_err}" + )); + } + } + (Ok(v), None) => { + ran += 1; + failures.push(format!("{case}: expected error, got {v}")); + } + (Err(e), Some(_)) => { + ran += 1; + failures.push(format!("{case}: unexpected error {e}")); + } + } + } + eprintln!("behavior fixtures run: {ran}"); + assert!(failures.is_empty(), "{}", failures.join("\n\n")); +} diff --git a/browser/tests/browser_compat.rs b/browser/tests/browser_compat.rs new file mode 100644 index 000000000..7e8fed29d --- /dev/null +++ b/browser/tests/browser_compat.rs @@ -0,0 +1,254 @@ +#![cfg(feature = "scrapling-compat")] + +use std::path::PathBuf; +use std::time::Duration; + +use browser::config::{SecurityMode, WorkerConfig}; +use browser::scrapling::raw_browser::{RawBrowser, RawBrowserOptions}; +use serde_json::json; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +async fn origin() -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + tokio::spawn(async move { + let mut request = [0; 8192]; + let Ok(size) = socket.read(&mut request).await else { + return; + }; + let Some(path) = std::str::from_utf8(&request[..size]) + .ok() + .and_then(|request| request.split_whitespace().nth(1)) + else { + return; + }; + let (status, headers, body) = match path { + "/plain" => ( + "206 Partial Content", + "Content-Type: text/plain; charset=iso-8859-1\r\nX-Test: plain\r\n", + b"caf\xe9".to_vec(), + ), + "/visual" => ( + "200 OK", + "Content-Type: text/html; charset=utf-8\r\n", + include_bytes!("corpus/browser_visual.html").to_vec(), + ), + _ => ( + "200 OK", + "Content-Type: text/html; charset=utf-8\r\nX-Test: one\r\nX-Test: two\r\nSet-Cookie: sid=abc; Path=/\r\n", + br#"<!doctype html><html><head><title>T</title></head><body><h1>initial</h1><div id="fp"></div><script>document.querySelector('h1').textContent='rendered';document.querySelector('#fp').textContent=[navigator.language,Intl.DateTimeFormat().resolvedOptions().timeZone,devicePixelRatio,innerWidth,innerHeight].join('|')</script></body></html>"#.to_vec(), + ), + }; + let response = format!( + "HTTP/1.1 {status}\r\n{headers}Date: Wed, 12 Aug 2026 16:00:00 GMT\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + if socket.write_all(response.as_bytes()).await.is_ok() { + let _ = socket.write_all(&body).await; + } + }); + } + }); + (format!("http://{address}"), task) +} + +fn assert_image_close(label: &str, actual: &[u8], expected: &[u8]) { + let actual = image::load_from_memory(actual).unwrap().to_rgba8(); + let expected = image::load_from_memory(expected).unwrap().to_rgba8(); + assert_eq!(actual.dimensions(), expected.dimensions()); + let mut total_error = 0u64; + let mut within_three = 0usize; + let mut maximum = 0u8; + for (actual, expected) in actual.as_raw().iter().zip(expected.as_raw()) { + let error = actual.abs_diff(*expected); + total_error += u64::from(error); + within_three += usize::from(error <= 3); + maximum = maximum.max(error); + } + let channels = actual.as_raw().len(); + let mean = total_error as f64 / channels as f64; + assert!(mean <= 0.5, "{label}: mean error {mean}"); + assert!( + within_three * 1_000 >= channels * 999, + "{label}: {} of {channels} channels within 3", + within_three + ); + assert!(maximum <= 12, "{label}: maximum error {maximum}"); +} + +fn config() -> WorkerConfig { + let mut config = WorkerConfig::default(); + config.scrapling.security_mode = SecurityMode::Compat; + config.scrapling.chromium_executable = PathBuf::from( + std::env::var_os("SCRAPLING_CHROMIUM_EXECUTABLE") + .expect("SCRAPLING_CHROMIUM_EXECUTABLE must name frozen Chrome 148"), + ) + .display() + .to_string(); + config +} + +#[tokio::test] +async fn browser_response_matches_frozen_dynamic_and_stealth_contracts() { + let (origin, server) = origin().await; + for (stealth, viewport) in [(false, "1280|720"), (true, "1920|1080")] { + let options = RawBrowserOptions::from_payload(&json!({ + "retries": 1, + "timeout": 5_000, + "locale": "fr-FR", + "timezone_id": "Europe/Paris" + })) + .unwrap(); + let browser = RawBrowser::start(&config(), &options, stealth, false) + .await + .unwrap(); + let page = browser + .fetch(&format!("{origin}/page"), &options, stealth) + .await + .unwrap(); + assert_eq!(page.status, Some(200)); + assert_eq!(page.headers["x-test"], "one, two"); + assert_eq!(page.cookies, serde_json::Map::new()); + assert_eq!(page.encoding.as_deref(), Some("utf-8")); + assert!(page.html.contains("<h1>rendered</h1>"), "{}", page.html); + assert!( + page.html + .contains(&format!("fr-FR|Europe/Paris|2|{viewport}")), + "{}", + page.html + ); + + let plain = browser + .fetch(&format!("{origin}/plain"), &options, stealth) + .await + .unwrap(); + assert_eq!(plain.status, Some(206)); + assert_eq!(plain.encoding.as_deref(), Some("iso-8859-1")); + assert_eq!(plain.html, "<html><body>café</body></html>"); + } + server.abort(); +} + +#[tokio::test] +async fn browser_waits_match_the_frozen_timing_contract() { + let (origin, server) = origin().await; + let options = RawBrowserOptions::from_payload(&json!({ + "retries": 1, + "timeout": 5_000, + "wait": 25 + })) + .unwrap(); + let browser = RawBrowser::start(&config(), &options, false, false) + .await + .unwrap(); + let started = std::time::Instant::now(); + browser + .fetch(&format!("{origin}/page"), &options, false) + .await + .unwrap(); + assert!(started.elapsed() >= Duration::from_millis(25)); + server.abort(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn five_browser_processes_run_within_the_release_envelope() { + let (origin, server) = origin().await; + let jobs = (0..5).map(|_| { + let origin = origin.clone(); + async move { + let options = RawBrowserOptions::from_payload(&json!({ + "retries": 1, + "timeout": 10_000 + })) + .unwrap(); + let browser = RawBrowser::start(&config(), &options, false, false) + .await + .unwrap(); + let page = browser + .fetch(&format!("{origin}/page"), &options, false) + .await + .unwrap(); + browser.shutdown().await; + page + } + }); + for page in futures::future::join_all(jobs).await { + assert_eq!(page.status, Some(200)); + assert!(page.html.contains("<h1>rendered</h1>")); + } + server.abort(); +} + +#[tokio::test] +async fn screenshots_match_the_frozen_wrapper_metrics_and_pixels() { + let manifest: serde_json::Value = + serde_json::from_str(include_str!("golden/browser/manifest.json")).unwrap(); + let (origin, server) = origin().await; + for case in manifest["cases"].as_array().unwrap() { + let request = &case["request"]; + let stealth = request["fetcher"] == "stealthy"; + let options = RawBrowserOptions::from_payload(request).unwrap(); + let browser = RawBrowser::start(&config(), &options, stealth, false) + .await + .unwrap(); + let (content, mime, final_url) = browser + .screenshot( + &format!("{origin}/visual"), + &options, + stealth, + request["full_page"].as_bool().unwrap(), + request["format"].as_str().unwrap(), + ) + .await + .unwrap(); + assert_eq!(mime, case["response"]["mime"]); + assert_eq!(final_url, format!("{origin}/visual")); + let expected = case["response"]["content"].as_array().unwrap(); + assert_eq!(content.len(), expected.len()); + for (actual, expected) in content.iter().zip(expected) { + assert_eq!(actual["type"], expected["type"]); + if actual["type"] == "image" { + assert_eq!(actual["mime"], expected["mime"]); + let actual = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + actual["data"].as_str().unwrap(), + ) + .unwrap(); + let filename = expected["file"].as_str().unwrap(); + let expected = match filename { + "dynamic-viewport-png-1.png" => { + include_bytes!("golden/browser/dynamic-viewport-png-1.png").as_slice() + } + "dynamic-full-png-1.png" => { + include_bytes!("golden/browser/dynamic-full-png-1.png").as_slice() + } + "stealthy-viewport-png-1.png" => { + include_bytes!("golden/browser/stealthy-viewport-png-1.png").as_slice() + } + "stealthy-full-jpeg-1.jpg" => { + include_bytes!("golden/browser/stealthy-full-jpeg-1.jpg").as_slice() + } + name => panic!("unexpected browser fixture {name}"), + }; + assert_image_close(filename, &actual, expected); + } else { + assert_eq!( + actual["text"], + expected["text"] + .as_str() + .unwrap() + .replace("{origin}", &origin) + ); + } + } + } + server.abort(); +} diff --git a/browser/tests/cdp_private.rs b/browser/tests/cdp_private.rs new file mode 100644 index 000000000..0edc05263 --- /dev/null +++ b/browser/tests/cdp_private.rs @@ -0,0 +1,299 @@ +#[allow(dead_code)] +#[path = "../src/scrapling/cdp.rs"] +mod cdp; + +#[cfg(unix)] +mod unix { + use std::io::{Read, Write}; + use std::os::unix::net::UnixStream; + use std::process::Command; + use std::sync::mpsc; + use std::thread; + + use serde_json::{json, Value}; + + use super::cdp::{CdpClient, CdpError, EventError, REMOTE_DEBUGGING_PIPE_ARG}; + + fn read_frame(stream: &mut UnixStream) -> Option<Value> { + let mut bytes = Vec::new(); + let mut byte = [0]; + loop { + match stream.read(&mut byte).unwrap() { + 0 if bytes.is_empty() => return None, + 0 => panic!("truncated CDP frame"), + _ if byte[0] == 0 => return Some(serde_json::from_slice(&bytes).unwrap()), + _ => bytes.push(byte[0]), + } + } + } + + fn write_frame(stream: &mut UnixStream, value: &Value) { + serde_json::to_writer(&mut *stream, value).unwrap(); + stream.write_all(&[0]).unwrap(); + stream.flush().unwrap(); + } + + fn write_chunked_frames(stream: &mut UnixStream, values: &[Value]) { + let mut bytes = Vec::new(); + for value in values { + serde_json::to_writer(&mut bytes, value).unwrap(); + bytes.push(0); + } + let split = 3.min(bytes.len()); + stream.write_all(&bytes[..split]).unwrap(); + stream.write_all(&bytes[split..]).unwrap(); + stream.flush().unwrap(); + } + + #[tokio::test] + async fn fake_pipe_frames_routes_cancels_and_tears_down() { + let (client_commands, mut server_commands) = UnixStream::pair().unwrap(); + let (mut server_events, client_events) = UnixStream::pair().unwrap(); + let (closed_tx, closed_rx) = mpsc::channel(); + + let server = thread::spawn(move || { + let root = read_frame(&mut server_commands).unwrap(); + let page = read_frame(&mut server_commands).unwrap(); + assert_eq!( + root, + json!({"id": 1, "method": "Browser.getVersion", "params": {}}) + ); + assert_eq!( + page, + json!({"id": 2, "method": "Page.getFrameTree", "params": {}, "sessionId": "page-1"}) + ); + + write_chunked_frames( + &mut server_events, + &[ + json!({"method": "Browser.downloadWillBegin", "params": {"guid": "g"}}), + json!({"method": "Page.loadEventFired", "params": {"timestamp": 1}, "sessionId": "page-1"}), + json!({"id": 2, "result": {"wrong": true}, "sessionId": "page-2"}), + json!({"id": 2, "result": {"frameTree": "page"}, "sessionId": "page-1"}), + json!({"id": 1, "result": {"product": "Chrome"}}), + ], + ); + + let cancelled = read_frame(&mut server_commands).unwrap(); + assert_eq!(cancelled["id"], 3); + write_frame( + &mut server_events, + &json!({"id": 3, "result": {"late": true}}), + ); + + let after_cancel = read_frame(&mut server_commands).unwrap(); + assert_eq!(after_cancel["id"], 4); + write_frame( + &mut server_events, + &json!({"id": 4, "error": {"code": -32000, "message": "boom"}}), + ); + + assert!(read_frame(&mut server_commands).is_none()); + drop(server_events); + closed_tx.send(()).unwrap(); + }); + + let child = Command::new("sh") + .args(["-c", "exec sleep 60"]) + .spawn() + .unwrap(); + let client = CdpClient::from_pipe(client_events, client_commands, Some(child)).unwrap(); + let mut browser_events = client.subscribe(); + let page = client.session("page-1"); + let mut page_events = page.subscribe(); + + let root_call = client.send("Browser.getVersion", json!({})).unwrap(); + let page_call = page.send("Page.getFrameTree", json!({})).unwrap(); + let (root_result, page_result) = tokio::join!(root_call, page_call); + assert_eq!(root_result.unwrap(), json!({"product": "Chrome"})); + assert_eq!(page_result.unwrap(), json!({"frameTree": "page"})); + + let browser_event = browser_events.recv().await.unwrap(); + assert_eq!(browser_event.method, "Browser.downloadWillBegin"); + assert_eq!(browser_event.session_id, None); + let page_event = page_events.recv().await.unwrap(); + assert_eq!(page_event.method, "Page.loadEventFired"); + assert_eq!(page_event.session_id.as_deref(), Some("page-1")); + + let cancelled = client + .send("Runtime.evaluate", json!({"expression": "1"})) + .unwrap(); + drop(cancelled); + tokio::task::yield_now().await; + assert_eq!(client.pending_count(), 0); + + let error = client + .send("Broken.command", json!({})) + .unwrap() + .await + .unwrap_err(); + assert!(matches!(error, CdpError::Protocol { code: -32000, .. })); + + client.close().unwrap(); + closed_rx.recv().unwrap(); + server.join().unwrap(); + assert!(client.is_closed()); + assert!(client.process_status().is_some()); + assert!(matches!( + browser_events.recv().await, + Err(EventError::Closed) + )); + assert_eq!(REMOTE_DEBUGGING_PIPE_ARG, "--remote-debugging-pipe"); + } + + #[tokio::test] + async fn launcher_maps_chromes_fd3_and_fd4_and_owns_the_child() { + let unique = format!( + "{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let capture = std::env::temp_dir().join(format!("cdp-launch-{unique}.json")); + let expected = b"{\"id\":1,\"method\":\"Browser.getVersion\",\"params\":{}}\0"; + let script = r#" + test "$0" = "--remote-debugging-pipe" || exit 91 + dd bs=1 count="$FRAME_LEN" <&3 >"$CAPTURE" 2>/dev/null || exit 92 + printf '{"id":1,"result":{"product":"FakeChrome"}}\0' >&4 || exit 93 + exec sleep 60 + "#; + let mut command = Command::new("sh"); + command + .arg("-c") + .arg(script) + .env("FRAME_LEN", expected.len().to_string()) + .env("CAPTURE", &capture); + + let client = CdpClient::launch_pipe(&mut command).unwrap(); + assert_eq!( + client + .send("Browser.getVersion", json!({})) + .unwrap() + .await + .unwrap(), + json!({"product": "FakeChrome"}) + ); + client.close().unwrap(); + assert!(client.process_status().is_some()); + assert_eq!(std::fs::read(&capture).unwrap(), expected); + std::fs::remove_file(capture).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn launcher_spawn_error_closes_every_pipe_descriptor() { + const ISOLATED_CHECK: &str = "BROWSER_CDP_FD_LEAK_CHECK"; + + fn descriptor_count() -> usize { + std::fs::read_dir("/proc/self/fd").unwrap().count() + } + + if std::env::var_os(ISOLATED_CHECK).is_some() { + let before = descriptor_count(); + let mut command = Command::new("/definitely/not/a/chrome/executable"); + let error = CdpClient::launch_pipe(&mut command).unwrap_err(); + assert!(matches!(error, CdpError::Transport(_))); + assert_eq!(descriptor_count(), before); + return; + } + + let output = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "unix::launcher_spawn_error_closes_every_pipe_descriptor", + ]) + .env(ISOLATED_CHECK, "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "isolated descriptor check failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + #[tokio::test] + async fn websocket_routes_root_and_session_messages_and_closes() { + use futures::{SinkExt, StreamExt}; + use tokio::net::TcpListener; + use tokio_tungstenite::{accept_async, tungstenite::Message}; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = accept_async(stream).await.unwrap(); + let root: Value = + serde_json::from_str(socket.next().await.unwrap().unwrap().to_text().unwrap()) + .unwrap(); + let page: Value = + serde_json::from_str(socket.next().await.unwrap().unwrap().to_text().unwrap()) + .unwrap(); + assert_eq!( + root, + json!({"id": 1, "method": "Browser.getVersion", "params": {}}) + ); + assert_eq!(page["sessionId"], "page-ws"); + socket + .send(Message::Text( + json!({"method": "Page.loadEventFired", "params": {}, "sessionId": "page-ws"}) + .to_string() + .into(), + )) + .await + .unwrap(); + socket + .send(Message::Text( + json!({"id": 2, "result": {"frameTree": "ws"}, "sessionId": "page-ws"}) + .to_string() + .into(), + )) + .await + .unwrap(); + socket + .send(Message::Text( + json!({"id": 1, "result": {"product": "WebSocketChrome"}}) + .to_string() + .into(), + )) + .await + .unwrap(); + matches!(socket.next().await, Some(Ok(Message::Close(_)))) + }); + + let client = + CdpClient::connect_websocket_url(&format!("ws://{address}/devtools/browser/fake")) + .await + .unwrap(); + let page = client.session("page-ws"); + let mut page_events = page.subscribe(); + let root_call = client.send("Browser.getVersion", json!({})).unwrap(); + let page_call = page.send("Page.getFrameTree", json!({})).unwrap(); + let (root, frame) = tokio::join!(root_call, page_call); + assert_eq!(root.unwrap(), json!({"product": "WebSocketChrome"})); + assert_eq!(frame.unwrap(), json!({"frameTree": "ws"})); + assert_eq!( + page_events.recv().await.unwrap().method, + "Page.loadEventFired" + ); + client.close().unwrap(); + assert!(server.await.unwrap()); + } + + #[tokio::test] + async fn websocket_rejects_non_cdp_schemes_before_connecting() { + let error = CdpClient::connect_websocket_url("http://127.0.0.1/devtools/browser/id") + .await + .unwrap_err(); + assert!(matches!(error, CdpError::UnsupportedTransport(_))); + assert!(error.to_string().contains("ws:// or wss://")); + + let error = CdpClient::connect_websocket_url("wss://127.0.0.1:1/devtools/browser/id") + .await + .unwrap_err(); + assert!(matches!(error, CdpError::Transport(_))); + } +} diff --git a/browser/tests/corpus/basic.html b/browser/tests/corpus/basic.html new file mode 100644 index 000000000..a347e2bc2 --- /dev/null +++ b/browser/tests/corpus/basic.html @@ -0,0 +1 @@ +<html><head></head><body><h1 class="t">Hello</h1><ul><li><a href="/a">Apple</a></li><li><a href="/b">Banana</a></li></ul><p>price 42 usd then 99</p></body></html> diff --git a/browser/tests/corpus/browser_visual.html b/browser/tests/corpus/browser_visual.html new file mode 100644 index 000000000..c8374b179 --- /dev/null +++ b/browser/tests/corpus/browser_visual.html @@ -0,0 +1,8 @@ +<!doctype html><html><head><meta charset="utf-8"><style> +html,body{margin:0;padding:0} +.a{height:360px;background:#123456} +.b{height:360px;background:#f08020} +.c{height:360px;background:#20a060} +.d{height:360px;background:#8030c0} +.e{height:360px;background:#e0d040} +</style></head><body><div class="a"></div><div class="b"></div><div class="c"></div><div class="d"></div><div class="e"></div></body></html> diff --git a/browser/tests/corpus/edge.html b/browser/tests/corpus/edge.html new file mode 100644 index 000000000..e0c38bef3 --- /dev/null +++ b/browser/tests/corpus/edge.html @@ -0,0 +1,17 @@ +<html><head></head><body> +<div id="wrap" class="outer main"> + <span>CONDITION: <!-- separator -->Excellent</span> + <p> <b>bold</b>after-bold</p> + <p>lead<b>mid</b>tail</p> + <ul><li>one</li><li>two</li><li>three</li></ul> + <a href="/x?a=1&amp;b=2" data-price="10">A&amp;B</a> + <input disabled type="text"> + <script>var hidden = "never";</script> + <style>.x { display: none; }</style> + <p class="uni">Ünïcode — café</p> + <textarea> </textarea> + <div>line one +and two <b>x</b></div> + <div data-x="1">line oneand two <b>y</b></div> +</div> +</body></html> diff --git a/browser/tests/corpus/messy.html b/browser/tests/corpus/messy.html new file mode 100644 index 000000000..7bd4716d6 --- /dev/null +++ b/browser/tests/corpus/messy.html @@ -0,0 +1,19 @@ +<html><head><title>Messy Page</title><meta charset="utf-8"></head><body> +<nav><ul><li><a href="/home">Home</a></li><li><a href="/about">About</a></li></ul></nav> +<template><p>never render this</p></template> +<div aria-hidden="true">screen-reader trap</div> +<div style="display:none">hidden A</div> +<div style="visibility: hidden">hidden B</div> +<main> + <h1>Widget Review</h1> + <p>Intro paragraph with <em>emphasis</em> and a <a href="/w/1">link</a>.</p> + <h2>Specs</h2> + <table><tr><th>Name</th><th>Value</th></tr><tr><td>Weight</td><td>3kg</td></tr></table> + <ul><li>alpha<ul><li>nested</li></ul></li><li>beta</li></ul> + <div class="card" data-id="1"><h3>Card One</h3><p>first card</p></div> + <div class="card" data-id="2"><h3>Card Two</h3><p>second card</p></div> + <div class="card wide" data-id="3"><h3>Card Three</h3><p>third card</p></div> +</main> +<footer><p>© 2026 Example — <span style="font-size:0">invisible</span>fine print</p></footer> +<script>analytics()</script> +</body></html> diff --git a/browser/tests/cssselect_compat.rs b/browser/tests/cssselect_compat.rs new file mode 100644 index 000000000..b0941ded2 --- /dev/null +++ b/browser/tests/cssselect_compat.rs @@ -0,0 +1,29 @@ +use cssselect::HtmlTranslator; + +#[test] +fn scrapling_pseudo_elements_translate_to_xpath() { + let translator = HtmlTranslator::new(); + for (css, xpath) in [ + ("a::text", "descendant-or-self::a/text()"), + ( + "a ::text", + "descendant-or-self::a/descendant-or-self::text()", + ), + ("::text", "descendant-or-self::text()"), + ("a::attr(href)", "descendant-or-self::a/@href"), + ( + "a ::attr(href)", + "descendant-or-self::a/descendant-or-self::*/@href", + ), + ( + "h1 + p::text", + "descendant-or-self::h1/following-sibling::*[(self::p) and (position() = 1)]/text()", + ), + ( + "h1 ~ p::attr(data-x)", + "descendant-or-self::h1/following-sibling::p/@data-x", + ), + ] { + assert_eq!(translator.css_to_xpath(css).unwrap(), xpath, "{css}"); + } +} diff --git a/browser/tests/e2e/.gitignore b/browser/tests/e2e/.gitignore new file mode 100644 index 000000000..5010da0b5 --- /dev/null +++ b/browser/tests/e2e/.gitignore @@ -0,0 +1,16 @@ +node_modules/ +dist/ +reports/*.log +reports/report.json +reports/config.runtime.yaml +reports/browser.runtime.json +reports/elements.db +!reports/.gitkeep +# The engine's builtin `configuration` worker persists any config it +# resolves through its fs adapter to `./config/<id>.yaml`, relative to CWD +# (engine/src/workers/configuration/adapters/fs.rs's DEFAULT_DIRECTORY, in +# the iii engine repo) — observed locally for `iii-observability`. +# run-tests.sh removes this directory before each run; ignore it too in case +# a run is interrupted before that cleanup fires on the next one. +/config/ +.DS_Store diff --git a/browser/tests/e2e/README.md b/browser/tests/e2e/README.md new file mode 100644 index 000000000..035dd3bbc --- /dev/null +++ b/browser/tests/e2e/README.md @@ -0,0 +1,163 @@ +# browser worker — end-to-end harness + +Self-asserting smoke harness for the `browser` worker. Validates +all 10 native `browser::*` parse functions (extract, css, xpath, regex, find, +find-by-text, find-by-regex, find-similar, describe, to-markdown) over the +real iii bus — worker built and run as a real binary, engine as a real +process, harness as a real WebSocket client — plus adaptive/XPath compatibility, +outbound policy errors, browser rendering/screenshots, crawl validation, and +private HTTP/dynamic/stealthy session lifecycle. + +Modeled on `database/tests/e2e/` in this repo, trimmed for this worker: it needs +no database driver or dialect matrix. Most parse cases are stateless; the +adaptive case exercises the worker-managed SQLite path. No Docker service or +application schema is required. + +Runs locally and in CI (`.github/workflows/browser-scrapling-e2e.yml`). + +## Prerequisites + +- Rust toolchain (`cargo` on `$PATH`) +- Node.js 20+ (`npm` on `$PATH`) +- The iii engine on `$PATH`. Install with: + ```sh + curl -fsSL https://install.iii.dev/iii/main/install.sh | sh + ``` + The script drops the binary at `$HOME/.local/bin/iii` (override with + `BIN_DIR=...` or `PREFIX=...`). + +## IMPORTANT: port isolation + +**Do not run this suite against a stack that has another browser worker +registered.** Python uses distinct `scrapling::*` ids; this native worker uses +root `browser::*` ids. A second browser worker at the same ids can still win +dispatch and silently test the wrong implementation. + +For that reason this suite does **not** default to the iii engine's own +default port (49134), which a local dev stack commonly already occupies. +`config.yaml` overrides `iii-worker-manager`'s (the engine's own builtin +WebSocket-listener worker) `port` to **49234** by default. `run-tests.sh`: + +1. Preflights `E2E_PORT` (49234 unless overridden) and hard-fails if + anything is already bound there — it never kills, reconfigures, or + reuses another running engine. +2. Threads `E2E_PORT` through the engine config, the worker's `--url`, and + the harness's `III_URL` so all three always agree. + +Override with `E2E_PORT=<port> ./run-tests.sh` if 49234 is also taken. + +There is no `--port` flag on the `iii` CLI and no `iii start` subcommand — +the override lives entirely in config.yaml, on the builtin +`iii-worker-manager` worker's `config.port` key (see the comment in +`config.yaml`). + +## Run + +```sh +./run-tests.sh # full suite +./run-tests.sh --filter=xpath # only cases whose name contains "xpath" +``` + +Builds the worker (`cargo build --release --bin browser`), starts +the engine, starts the browser worker, and runs the harness. Exits +0 on PASS, 1 on any FAIL. + +### Startup order + +1. iii engine (`config.yaml`, booted from an untracked + `reports/config.runtime.yaml` copy — see "Config-rewrite dodge" below) +2. browser worker binary (host process, `--url ws://127.0.0.1:$E2E_PORT`) +3. Harness test suite (`npm run dev`) + +Neither the worker nor the harness is engine-managed; both connect over +WebSocket like external clients. The harness writes a runtime-only worker seed +that enables loopback and points at the downloaded frozen Chrome artifact. + +### Config-rewrite dodge + +The iii engine is known to rewrite the config file it boots from (observed +on `database/tests/e2e/config.yaml`, a tracked file, in this repo). +`run-tests.sh` copies `config.yaml` to the untracked +`reports/config.runtime.yaml` at startup and points `iii -c` at that copy, +so a local run never dirties the tracked config. Verify with `git status` +after a run. + +## Flags + +| Flag | Effect | +|---|---| +| `--keep` | Leave the engine + worker running after the run (debugging) | +| `--no-build` | Skip the cargo build step | +| `--filter=X` | Run only harness cases whose name contains `X` (substring match) | +| `-h`, `--help` | Print usage | + +## Env overrides + +The script auto-detects paths relative to its own location, but each can be +overridden: + +| Var | Default | Purpose | +|---|---|---| +| `E2E_PORT` | `49234` | Engine WebSocket port — see "port isolation" above | +| `WORKER_SRC` | `../..` (the `browser/` crate) | Where to `cargo build` | +| `III_BIN` | `$(command -v iii)` then `$HOME/.local/bin/iii` | Engine binary | +| `WORKER_BIN_TARGET` | `$WORKER_SRC/target/release/browser` | Built worker | +| `HARNESS_TIMEOUT` | `120` | Seconds to wait for the harness sentinel | + +## Layout + +| File | Role | +|---|---| +| `run-tests.sh` | Orchestrator | +| `config.yaml` | Engine infra only (worker-manager port override + observability) | +| `workers/harness/` | TypeScript smoke-test worker (runs as a host process) | +| `workers/harness/src/cases.ts` | All 27 cases; parse expectations come from `../../../../tests/golden/behavior/**`, while outbound cases exercise hermetic validation/state paths | +| `workers/harness/src/runner.ts` | Runs the cases, records pass/fail, writes `reports/report.json` | +| `workers/harness/src/worker.ts` | Entry point; registers with the bus, emits the `HARNESS_DONE` sentinel | +| `reports/report.json` | Per-case results (latest run) | + +## Cases + +The suite currently contains 27 cases: ten parse-function examples, adaptive +and XPath compatibility, limit/error cases, outbound security policy, crawl +validation, and a private HTTP-session lifecycle. The authoritative names and +assertions live in `workers/harness/src/cases.ts`; keep this summary grouped so +it does not become a second manually numbered source of truth. + +| # | Case | Asserts | +|---|---|---| +| 1 | `extract` mixed selector list | css/xpath precedence, regex short-circuit, `attr`, `html`, `all` | +| 2 | `css` first + attr | `first` scalar result, `attr` pulls an attribute over text | +| 3 | `xpath` positional predicate | `//li[2]/a`, first, text | +| 4 | `regex` first capture group | `first: true` returns the group, not the whole match | +| 5 | `find` tag + attrs | combined filter narrows to the same set as attrs alone | +| 6 | `find-by-text` leading-text match | exact match on the leading text run | +| 7 | `find-by-regex` leading-text pattern | case-insensitive default | +| 8 | `find-similar` li anchor | count 2, default `{text, html}` item shape | +| 9 | `describe` h1 | `found`, `classes`, `parent_tag`, full identity object | +| 10 | `to-markdown` text mode | exact string (deterministic; no markdown formatting choices) | +| 11 | `css` adaptive direct match | adaptive query succeeds without saving fixture state | +| 12 | `css` invalid selector | error starting `Invalid CSS selector 'li:::bad':` | +| 13 | `xpath` ancestor axis | reverse-axis positional semantics | +| 14 | `find` limit 0 | items clamp to `[]`; `count` stays the true (pre-cap) total | +| 15–27 | outbound/browser/session | SSRF and safe-policy errors, dynamic/stealthy rendering, screenshot wire shape, crawl delivery, HTTP cookie state, UUID session metadata, persistent browser sessions, foreign-id rejection | + +## CI + +The harness runs in `.github/workflows/browser-scrapling-e2e.yml` on any PR +that touches `browser/**`. The workflow installs the engine via the +install script (always tracks `main`, no version pin), builds the worker, +and shells out to `./run-tests.sh`. CI runners are ephemeral, so the +port-isolation concern above doesn't apply there — 49234 is used anyway, for +parity with local runs. + +## Troubleshooting + +- **`port $E2E_PORT is already in use`**: something else — possibly a dev + engine — is bound to it. Stop it, or re-run with `E2E_PORT=<a-free-port>`. + This script never kills or reuses another running engine. +- **`worker binary missing`**: run without `--no-build` once. +- **`iii engine binary missing`**: install with the script above. +- **browser worker did not respond**: tail + `reports/browser-*.log`. +- **Sentinel timeout**: tail `reports/harness-*.log` for the harness output. diff --git a/browser/tests/e2e/config.yaml b/browser/tests/e2e/config.yaml new file mode 100644 index 000000000..892c4f41f --- /dev/null +++ b/browser/tests/e2e/config.yaml @@ -0,0 +1,38 @@ +# iii engine configuration — passed via `iii -c` (through run-tests.sh's +# untracked reports/config.runtime.yaml copy; see the comment in +# run-tests.sh for why it isn't booted from this tracked file directly). +# +# Infrastructure workers only. `iii-worker-manager` is the builtin worker +# that owns the engine's WebSocket listener (engine/src/workers/worker/mod.rs +# in the iii engine repo); its `port` is overridden to ${E2E_PORT:49234} — +# NOT the iii default of 49134 — so this suite never shares a port (and +# therefore never risks sharing root browser::* function registrations) with a +# locally running dev engine. The Python worker uses distinct scrapling::* ids; +# another browser worker at the same browser::* ids would silently test the +# wrong implementation. +# +# run-tests.sh exports E2E_PORT (default 49234) before starting the engine; +# override with `E2E_PORT=<port> ./run-tests.sh` if 49234 is also taken. +# `:49234` (no dash) is the iii engine's own config templating syntax +# (EngineConfig::expand_env_vars), not bash's — it's only a fallback for +# anyone booting this file directly with `iii -c config.yaml`, bypassing +# run-tests.sh's own preflight port check. +# +# The browser worker and harness are NOT registered here. +# run-tests.sh: +# 1. starts this engine +# 2. spawns the browser binary as a host process (--url ws://…) +# 3. runs the harness test suite + +workers: + - name: iii-worker-manager + config: + port: ${E2E_PORT:49234} + + - name: iii-observability + config: + enabled: true + service_name: browser-tests + exporter: memory + logs_console_output: true + sampling_ratio: 1.0 diff --git a/browser/tests/e2e/reports/.gitkeep b/browser/tests/e2e/reports/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/browser/tests/e2e/run-tests.sh b/browser/tests/e2e/run-tests.sh new file mode 100755 index 000000000..1906e439f --- /dev/null +++ b/browser/tests/e2e/run-tests.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Path overrides (set in CI; defaults assume the harness lives at +# browser/tests/e2e/ inside the workers repo and the iii engine is +# on $PATH or at $HOME/.local/bin/iii — which is where the install script +# `curl -fsSL https://install.iii.dev/iii/main/install.sh | sh` puts it). +WORKER_SRC="${WORKER_SRC:-$(cd "$ROOT_DIR/../.." && pwd)}" +III_BIN="${III_BIN:-$(command -v iii 2>/dev/null || echo "$HOME/.local/bin/iii")}" +WORKER_BIN_TARGET="${WORKER_BIN_TARGET:-$WORKER_SRC/target/release/browser}" + +# Port isolation. This machine commonly runs a shared dev engine on the iii +# default port (49134) that already has a `browser` worker registered — some +# other build, from some other worktree. Triggering against it would silently +# test the wrong binary while reporting a pass. (The namespace rename to +# root `browser::*` ids do not collide with the PYTHON scrapling worker's +# `scrapling::*` ids, but "wrong build of the same +# worker" is the sharper risk and it is not fixed by namespacing.) +# The iii CLI has no `--port` flag +# and no `iii start` subcommand (checked `iii --help`); the override lives in +# config.yaml instead, on the builtin `iii-worker-manager` worker's `port` +# key (confirmed by reading the iii engine source, +# engine/src/workers/worker/mod.rs's `WorkerManagerConfig` — the same knob +# `sdk/fixtures/config-test.yaml` uses there). So: default to a DIFFERENT +# port than the iii default, and thread it through engine config, the +# worker's --url, and the harness's III_URL. +export E2E_PORT="${E2E_PORT:-49234}" + +REPORT_PATH="$ROOT_DIR/reports/report.json" +TS=$(date +%Y%m%d-%H%M%S) +ENGINE_LOG="$ROOT_DIR/reports/engine-$TS.log" +WORKER_LOG="$ROOT_DIR/reports/browser-$TS.log" +HARNESS_LOG="$ROOT_DIR/reports/harness-$TS.log" +SENTINEL_TIMEOUT="${HARNESS_TIMEOUT:-120}" + +KEEP=0 +NO_BUILD=0 +FILTER="" + +for arg in "$@"; do + case "$arg" in + --keep) KEEP=1 ;; + --no-build) NO_BUILD=1 ;; + --filter=*) FILTER="${arg#--filter=}" ;; + -h|--help) + cat <<EOF +Usage: $0 [--keep] [--no-build] [--filter=<case-substring>] + + --keep Leave the engine + worker running after the run (debugging). + --no-build Skip cargo build of the browser worker. + --filter=X Run only harness cases whose name contains X (substring match; + there's no per-driver concept here). + +Env overrides: + E2E_PORT Engine WebSocket port (default: 49234 — deliberately + NOT the iii default of 49134, to avoid colliding with a + locally running dev stack that may have another + browser worker registered under the same browser::* ids). + Set to another port if 49234 is also taken. + WORKER_SRC Path to the browser crate (default: ../..). + III_BIN Path to the iii engine binary (default: \$(command -v iii) or \$HOME/.local/bin/iii). + WORKER_BIN_TARGET Path to the built worker binary (default: \$WORKER_SRC/target/release/browser). + HARNESS_TIMEOUT Seconds to wait for the harness sentinel (default: 120). +EOF + exit 0 + ;; + *) echo "unknown arg: $arg" >&2; exit 2 ;; + esac +done + +ENGINE_PID="" +WORKER_PID="" +HARNESS_PID="" +cleanup() { + local code=$? + if [[ -n "$HARNESS_PID" ]] && kill -0 "$HARNESS_PID" 2>/dev/null; then + kill "$HARNESS_PID" 2>/dev/null || true + wait "$HARNESS_PID" 2>/dev/null || true + fi + if [[ "$KEEP" -eq 0 && -n "$WORKER_PID" ]] && kill -0 "$WORKER_PID" 2>/dev/null; then + kill "$WORKER_PID" 2>/dev/null || true + wait "$WORKER_PID" 2>/dev/null || true + fi + if [[ "$KEEP" -eq 0 && -n "$ENGINE_PID" ]] && kill -0 "$ENGINE_PID" 2>/dev/null; then + kill "$ENGINE_PID" 2>/dev/null || true + wait "$ENGINE_PID" 2>/dev/null || true + fi + exit "$code" +} +trap cleanup EXIT INT TERM + +mkdir -p "$ROOT_DIR/reports" + +# 1. Port preflight — first, before the ~17s cargo build, so a bound port +# fails fast instead of after paying for a build we'd have to abort anyway. +# NEVER touch whatever is already listening — it may be someone's dev engine +# (see the E2E_PORT comment above). If our target port is already bound, +# hard-fail with a clear remediation instead of guessing whose engine it is +# or trying to reuse it. +if (echo > "/dev/tcp/127.0.0.1/$E2E_PORT") 2>/dev/null; then + echo "[run-tests] FATAL: port $E2E_PORT is already in use." >&2 + echo "[run-tests] This suite refuses to share a port with another running engine — it may" >&2 + echo "[run-tests] be a dev stack already running some other build of the browser worker," >&2 + echo "[run-tests] which would silently test the wrong binary and still report a pass." >&2 + echo "[run-tests] Stop whatever owns port $E2E_PORT, or re-run with E2E_PORT=<a-free-port>." >&2 + exit 1 +fi + +# 2. Build the worker (unless --no-build) +if [[ "$NO_BUILD" -eq 0 ]]; then + echo "[run-tests] cargo build --release --bin browser" + (cd "$WORKER_SRC" && cargo build --release --bin browser) +fi +if [[ ! -x "$WORKER_BIN_TARGET" ]]; then + echo "[run-tests] FATAL: worker binary missing at $WORKER_BIN_TARGET — run without --no-build" >&2 + exit 1 +fi + +# 3. Verify engine binary +if [[ ! -x "$III_BIN" ]]; then + echo "[run-tests] FATAL: iii engine binary missing at $III_BIN" >&2 + echo "[run-tests] install with: curl -fsSL https://install.iii.dev/iii/main/install.sh | sh" >&2 + exit 1 +fi + +# 4. Config-rewrite dodge. The iii engine is known to rewrite the config +# file it boots from (observed on database/tests/e2e/config.yaml, a tracked +# file, in this same repo). Boot from an untracked copy so a local run never +# dirties the tracked config. +RUNTIME_CONFIG="$ROOT_DIR/reports/config.runtime.yaml" +cp "$ROOT_DIR/config.yaml" "$RUNTIME_CONFIG" + +# Browser fetchers are certified against the frozen Chrome build. Reuse a +# caller-provided executable, otherwise verify the local artifact cache and +# fetch it only when absent. +if [[ -z "${SCRAPLING_CHROMIUM_EXECUTABLE:-}" ]]; then + CHROMIUM_OUTPUT="" + if ! CHROMIUM_OUTPUT=$("$WORKER_SRC/scripts/fetch_chromium_artifacts.sh" verify 2>/dev/null); then + CHROMIUM_OUTPUT=$("$WORKER_SRC/scripts/fetch_chromium_artifacts.sh" fetch) + fi + SCRAPLING_CHROMIUM_EXECUTABLE=$(awk -F= '/^SCRAPLING_CHROMIUM_EXECUTABLE=/{print substr($0, index($0,"=")+1)}' <<<"$CHROMIUM_OUTPUT") +fi +if [[ ! -x "$SCRAPLING_CHROMIUM_EXECUTABLE" ]]; then + echo "[run-tests] FATAL: frozen Chromium executable missing at $SCRAPLING_CHROMIUM_EXECUTABLE" >&2 + exit 1 +fi + +BROWSER_CONFIG="$ROOT_DIR/reports/browser.runtime.json" +python3 - "$BROWSER_CONFIG" "$SCRAPLING_CHROMIUM_EXECUTABLE" "$ROOT_DIR/reports/elements.db" <<'PY' +import json, pathlib, sys +pathlib.Path(sys.argv[1]).write_text(json.dumps({ + "scrapling": { + "security_mode": "safe", + "chromium_executable": sys.argv[2], + "allow_loopback": True, + "adaptive_storage_path": sys.argv[3], + } +})) +PY + +# The engine's builtin `configuration` worker also persists any config +# resolved through its fs adapter to ./config/<id>.yaml relative to CWD +# (observed for `iii-observability`) — reset it so a stale value from a +# previous run can never shadow what THIS run's config.yaml asks for. +rm -rf "$ROOT_DIR/config" + +# 5. Install harness deps if needed +if [[ ! -d "$ROOT_DIR/workers/harness/node_modules" ]]; then + echo "[run-tests] npm install (harness)" + (cd "$ROOT_DIR/workers/harness" && npm install --silent) +fi + +# 6. Start the engine (from the untracked runtime config copy; see step 4). +echo "[run-tests] starting iii engine on port $E2E_PORT" +: > "$ENGINE_LOG" +: > "$HARNESS_LOG" + +( cd "$ROOT_DIR" && "$III_BIN" --no-update-check -c "$RUNTIME_CONFIG" ) > "$ENGINE_LOG" 2>&1 & +ENGINE_PID=$! +echo "[run-tests] engine pid=$ENGINE_PID" + +# 7. Wait for the engine to accept TCP on $E2E_PORT. Probing the port +# directly instead of grepping for an engine log line decouples this script +# from the engine's logging format. +deadline=$(( $(date +%s) + 30 )) +while :; do + if (echo > "/dev/tcp/127.0.0.1/$E2E_PORT") 2>/dev/null; then + break + fi + if ! kill -0 "$ENGINE_PID" 2>/dev/null; then + echo "[run-tests] FATAL: engine exited before binding port; tail of engine log:" >&2 + tail -40 "$ENGINE_LOG" >&2 + exit 1 + fi + if (( $(date +%s) > deadline )); then + echo "[run-tests] FATAL: engine did not bind port $E2E_PORT within 30s; tail of engine log:" >&2 + tail -40 "$ENGINE_LOG" >&2 + exit 1 + fi + sleep 0.5 +done +echo "[run-tests] engine listening on $E2E_PORT" + +# 8. Start the browser worker as a host process (not engine-managed). +echo "[run-tests] starting browser worker" +: > "$WORKER_LOG" +( cd "$ROOT_DIR" && "$WORKER_BIN_TARGET" --config "$BROWSER_CONFIG" --url "ws://127.0.0.1:$E2E_PORT" ) > "$WORKER_LOG" 2>&1 & +WORKER_PID=$! +echo "[run-tests] browser worker pid=$WORKER_PID" + +# 9. Wait for browser::css to succeed (worker startup + function registration). +deadline=$(( $(date +%s) + 30 )) +while :; do + if "$III_BIN" trigger --port "$E2E_PORT" browser::css html='<p>x</p>' query='p' >/dev/null 2>&1; then + break + fi + if ! kill -0 "$WORKER_PID" 2>/dev/null; then + echo "[run-tests] FATAL: browser worker exited before becoming ready; tail of worker log:" >&2 + tail -40 "$WORKER_LOG" >&2 + exit 1 + fi + if (( $(date +%s) > deadline )); then + echo "[run-tests] FATAL: browser worker did not respond within 30s; tail of worker log:" >&2 + tail -40 "$WORKER_LOG" >&2 + exit 1 + fi + sleep 0.5 +done +echo "[run-tests] browser worker ready" + +# 10. Launch the harness as a host node process +echo "[run-tests] starting harness" +HARNESS_ENV=() +if [[ -n "$FILTER" ]]; then + HARNESS_ENV+=("HARNESS_FILTER=$FILTER") +fi +HARNESS_ENV+=("III_URL=ws://127.0.0.1:$E2E_PORT") +HARNESS_ENV+=("HARNESS_REPORT_PATH=$REPORT_PATH") + +( cd "$ROOT_DIR/workers/harness" && env "${HARNESS_ENV[@]}" npm run --silent dev ) > "$HARNESS_LOG" 2>&1 & +HARNESS_PID=$! +echo "[run-tests] harness pid=$HARNESS_PID" + +# 11. Wait for sentinel line +sentinel="" +deadline=$(( $(date +%s) + SENTINEL_TIMEOUT )) +while (( $(date +%s) < deadline )); do + if ! kill -0 "$HARNESS_PID" 2>/dev/null; then + if grep -m1 -E '^HARNESS_DONE: (PASS|FAIL) [0-9]+/[0-9]+$' "$HARNESS_LOG" >/dev/null 2>&1; then + sentinel=$(grep -m1 -E '^HARNESS_DONE: (PASS|FAIL) [0-9]+/[0-9]+$' "$HARNESS_LOG") + break + fi + echo "[run-tests] harness exited without sentinel; tail of harness log:" >&2 + tail -40 "$HARNESS_LOG" >&2 + exit 1 + fi + if grep -m1 -E '^HARNESS_DONE: (PASS|FAIL) [0-9]+/[0-9]+$' "$HARNESS_LOG" >/dev/null 2>&1; then + sentinel=$(grep -m1 -E '^HARNESS_DONE: (PASS|FAIL) [0-9]+/[0-9]+$' "$HARNESS_LOG") + break + fi + sleep 1 +done + +if [[ -z "$sentinel" ]]; then + echo "[run-tests] FATAL: harness did not emit sentinel within ${SENTINEL_TIMEOUT}s" >&2 + echo "[run-tests] tail of harness log:" >&2 + tail -40 "$HARNESS_LOG" >&2 + exit 1 +fi + +# 12. Print summary +echo +echo "=======================================================================" +echo "$sentinel" +if [[ -f "$REPORT_PATH" ]]; then + python3 - "$REPORT_PATH" <<'PY' 2>/dev/null || cat "$REPORT_PATH" +import json, sys +data = json.load(open(sys.argv[1])) +for r in data["results"]: + tag = "PASS" if r["status"] == "PASS" else "FAIL" + err = (" — " + r.get("error","")) if r["status"] == "FAIL" else "" + print(f" [{tag}] {r['case']}{err}") +PY +fi +echo "=======================================================================" + +case "$sentinel" in + *PASS*) exit 0 ;; + *) exit 1 ;; +esac diff --git a/browser/tests/e2e/workers/harness/package-lock.json b/browser/tests/e2e/workers/harness/package-lock.json new file mode 100644 index 000000000..53a1d629a --- /dev/null +++ b/browser/tests/e2e/workers/harness/package-lock.json @@ -0,0 +1,1126 @@ +{ + "name": "browser-tests-harness", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "browser-tests-harness", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "iii-sdk": "0.11.2" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.0.0", + "typescript": "^5.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", + "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz", + "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", + "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.57.2", + "@types/shimmer": "^1.2.0", + "import-in-the-middle": "^1.8.1", + "require-in-the-middle": "^7.1.1", + "semver": "^7.5.2", + "shimmer": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.57.2.tgz", + "integrity": "sha512-48IIRj49gbQVK52jYsw70+Jv+JbahT8BqT2Th7C4H7RCM9d0gZ5sgNPoMpWldmfjvIsSgiGJtjfk9MeZvjhoig==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.57.2", + "@opentelemetry/core": "1.30.1", + "@opentelemetry/resources": "1.30.1", + "@opentelemetry/sdk-logs": "0.57.2", + "@opentelemetry/sdk-metrics": "1.30.1", + "@opentelemetry/sdk-trace-base": "1.30.1", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/propagator-b3": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-1.30.1.tgz", + "integrity": "sha512-oATwWWDIJzybAZ4pO76ATN5N6FFbOA1otibAVlS8v90B4S1wClnhRUk7K+2CHAwN1JKYuj4jh/lpCEG5BAqFuQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-jaeger": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-1.30.1.tgz", + "integrity": "sha512-Pj/BfnYEKIOImirH76M4hDaBSx6HyZ2CXUqk+Kj02m6BB80c/yo4BdWkn/1gDFfU+YPY+bPR2U0DKBfdxCKwmg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz", + "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.57.2.tgz", + "integrity": "sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.57.2", + "@opentelemetry/core": "1.30.1", + "@opentelemetry/resources": "1.30.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.30.1.tgz", + "integrity": "sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/resources": "1.30.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz", + "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/resources": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-1.30.1.tgz", + "integrity": "sha512-cBjYOINt1JxXdpw1e5MlHmFRc5fgj4GW/86vsKFxJCJ8AL4PdVtYH41gWwl4qd4uQjqEL1oJVrXkSy5cnduAnQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "1.30.1", + "@opentelemetry/core": "1.30.1", + "@opentelemetry/propagator-b3": "1.30.1", + "@opentelemetry/propagator-jaeger": "1.30.1", + "@opentelemetry/sdk-trace-base": "1.30.1", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/shimmer": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz", + "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/iii-sdk": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/iii-sdk/-/iii-sdk-0.11.2.tgz", + "integrity": "sha512-S8/o53j1z+IOU6Mp1f3GbivJ59hEgWhtT6hNutVpfwhJK5Q9zS2rV2LUX1Ko6+xF/Zr3Y6xodNRmBRng0qiZZA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.57.0", + "@opentelemetry/core": "^1.30.0", + "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/otlp-transformer": "^0.57.0", + "@opentelemetry/resources": "^1.30.0", + "@opentelemetry/sdk-logs": "^0.57.0", + "@opentelemetry/sdk-metrics": "^1.30.0", + "@opentelemetry/sdk-trace-base": "^1.30.0", + "@opentelemetry/sdk-trace-node": "^1.30.0", + "@opentelemetry/semantic-conventions": "^1.28.0", + "ws": "^8.18.3" + } + }, + "node_modules/import-in-the-middle": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.15.0.tgz", + "integrity": "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.14.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^1.2.2", + "module-details-from-path": "^1.0.3" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/require-in-the-middle": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz", + "integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3", + "resolve": "^1.22.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==", + "license": "BSD-2-Clause" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tsx": { + "version": "4.23.11", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.11.tgz", + "integrity": "sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/browser/tests/e2e/workers/harness/package.json b/browser/tests/e2e/workers/harness/package.json new file mode 100644 index 000000000..e310fd5b8 --- /dev/null +++ b/browser/tests/e2e/workers/harness/package.json @@ -0,0 +1,20 @@ +{ + "name": "browser-tests-harness", + "version": "0.1.0", + "type": "module", + "private": true, + "description": "Self-asserting smoke harness for the iii browser worker.", + "scripts": { + "dev": "tsx src/worker.ts", + "build": "tsc" + }, + "dependencies": { + "iii-sdk": "0.11.2" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.0.0", + "typescript": "^5.4.0" + }, + "license": "Apache-2.0" +} diff --git a/browser/tests/e2e/workers/harness/src/cases.ts b/browser/tests/e2e/workers/harness/src/cases.ts new file mode 100644 index 000000000..fb04da640 --- /dev/null +++ b/browser/tests/e2e/workers/harness/src/cases.ts @@ -0,0 +1,595 @@ +/** + * browser has no database-style drivers or dialect matrix. Each case + * is a direct bus call, so there's no `CaseContext.driver` / + * `dialect` (contrast database's harness). Expected values below are NOT + * invented: each case is either a byte-for-byte copy of a request/response + * pair from ../../../../golden/behavior/**\/*.json (the differential fixtures + * the Rust `tests/behavior.rs` test replays against the reference Python + * implementation), or — where noted — a small, code-verified extension of + * one (e.g. adding a redundant `tag` alongside an already-golden `attrs` + * filter that provably selects the same element set; see the comment on + * that case). + */ + +export interface CaseContext { + /** Calls a browser function; returns parsed JSON or throws on engine error. */ + call: (functionId: string, payload: unknown) => Promise<any> + /** Hermetic loopback origin owned by the harness for outbound cases. */ + origin: string +} + +export interface TestCase { + name: string + run(ctx: CaseContext): Promise<void> +} + +/** Recursively sorts object keys so JSON.stringify comparison is order-insensitive + * (array element order still matters). browser's Cargo.toml enables + * serde_json's `preserve_order`, so the wire key order tracks *insertion* + * order in the Rust source — a cosmetic future reorder of a `json!({...})` + * literal shouldn't fail this suite. Same technique database's own + * cases-boundary.ts uses for its JSONB round-trip case (`canon`). */ +function canon(v: unknown): unknown { + if (Array.isArray(v)) return v.map(canon) + if (v !== null && typeof v === 'object') { + const out: Record<string, unknown> = {} + for (const k of Object.keys(v as Record<string, unknown>).sort()) { + out[k] = canon((v as Record<string, unknown>)[k]) + } + return out + } + return v +} + +export function expectEqual(actual: unknown, expected: unknown, msg: string): void { + if (JSON.stringify(canon(actual)) !== JSON.stringify(canon(expected))) { + throw new Error(`${msg}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`) + } +} + +export function expect(cond: boolean, msg: string): asserts cond { + if (!cond) throw new Error(msg) +} + +/** + * Asserts the complete error visible through the iii bus. Partial matches hide + * wrapper regressions, especially when two errors share a prefix. + */ +export async function expectError( + fn: () => Promise<unknown>, + expected: string, + label: string, +): Promise<void> { + try { + await fn() + } catch (e: any) { + const msg = e?.message ?? String(e) + if (msg !== expected) { + throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(msg)}`) + } + return + } + throw new Error(`${label}: expected the call to reject, but it resolved`) +} + +// Shared fixture HTML, copied verbatim (including the trailing newline) from +// tests/golden/behavior/{css,xpath,extract,regex,find,find-by-text, +// find-by-regex,find-similar,describe,to-markdown}/*.json — the same string +// backs 8 of the 10 happy-path cases below, exactly as it does across those +// golden fixtures. +const BASIC_HTML = + '<html><head></head><body><h1 class="t">Hello</h1><ul><li><a href="/a">Apple</a></li><li><a href="/b">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n' + +// Copied verbatim from tests/golden/behavior/find/limit_clamps.json (also +// backs find-by-regex/messy_cards.json and find/attrs_exact_whole_value.json). +const MESSY_HTML = + '<html><head><title>Messy Page</title><meta charset="utf-8"></head><body>\n<nav><ul><li><a href="/home">Home</a></li><li><a href="/about">About</a></li></ul></nav>\n<template><p>never render this</p></template>\n<div aria-hidden="true">screen-reader trap</div>\n<div style="display:none">hidden A</div>\n<div style="visibility: hidden">hidden B</div>\n<main>\n <h1>Widget Review</h1>\n <p>Intro paragraph with <em>emphasis</em> and a <a href="/w/1">link</a>.</p>\n <h2>Specs</h2>\n <table><tr><th>Name</th><th>Value</th></tr><tr><td>Weight</td><td>3kg</td></tr></table>\n <ul><li>alpha<ul><li>nested</li></ul></li><li>beta</li></ul>\n <div class="card" data-id="1"><h3>Card One</h3><p>first card</p></div>\n <div class="card" data-id="2"><h3>Card Two</h3><p>second card</p></div>\n <div class="card wide" data-id="3"><h3>Card Three</h3><p>third card</p></div>\n</main>\n<footer><p>© 2026 Example — <span style="font-size:0">invisible</span>fine print</p></footer>\n<script>analytics()</script>\n</body></html>\n' + +// Copied verbatim from the `const HTML` in src/xpath/eval.rs's test module +// (the fixture behind its `error_shapes` test, which asserts the exact +// "//p/ancestor::div" input this case reuses). +const ANCESTOR_HTML = '<main id="m"><section id="s"><p>P</p></section></main>' + +export const ORIGIN_PAGE_HTML = + '<!doctype html><html><head><title>E2E</title></head><body><h1>initial</h1><div id="fp"></div><a href="/leaf">leaf</a><script>document.querySelector("h1").textContent="rendered";document.querySelector("#fp").textContent=[navigator.language,Intl.DateTimeFormat().resolvedOptions().timeZone,devicePixelRatio,innerWidth,innerHeight].join("|")</script></body></html>' + +export const CASES: TestCase[] = [ + { + // Golden: tests/golden/behavior/extract/mixed_specs.json + name: 'extract: mixed selector list (css/xpath precedence, regex, attr, html, all)', + async run({ call }) { + const r = await call('browser::extract', { + html: BASIC_HTML, + selectors: [ + { name: 'title', css: 'h1' }, + { name: 'links', css: 'li a', attr: 'href', all: true }, + { name: 'names', css: 'li a', all: true }, + { name: 'price', regex: 'price (\\d+)' }, + { name: 'first_li_html', css: 'li', html: true }, + ], + }) + expectEqual( + r, + { + extracted: { + title: 'Hello', + links: ['/a', '/b'], + names: ['Apple', 'Banana'], + price: '42', + first_li_html: '<li><a href="/a">Apple</a></li>', + }, + }, + 'extract mixed_specs', + ) + }, + }, + { + // Golden: tests/golden/behavior/css/first_attr.json + name: 'css: first + attr pulls the attribute of the first match', + async run({ call }) { + const r = await call('browser::css', { html: BASIC_HTML, query: 'li a', first: true, attr: 'href' }) + expectEqual(r, { result: '/a' }, 'css first_attr') + }, + }, + { + // Golden: tests/golden/behavior/xpath/positional.json + name: 'xpath: positional predicate + child step, first (text)', + async run({ call }) { + const r = await call('browser::xpath', { html: BASIC_HTML, query: '//li[2]/a', first: true }) + expectEqual(r, { result: 'Banana' }, 'xpath positional') + }, + }, + { + // Golden: tests/golden/behavior/regex/first_group.json + name: 'regex: first capture group', + async run({ call }) { + const r = await call('browser::regex', { html: BASIC_HTML, pattern: 'price (\\d+)', first: true }) + expectEqual(r, { result: '42' }, 'regex first_group') + }, + }, + { + // Golden: tests/golden/behavior/find/attrs_exact_whole_value.json, on + // MESSY_HTML. That fixture filters by `attrs` alone (base selector + // `*[class="card"]`, per src/functions/find.rs's `op`: an empty `tag` + // falls back to `base = ["*"]` before the attrs suffix is appended). + // Adding `tag: "div"` narrows the base to `div[class="card"]` instead — + // provably the same match set here, since every `class="card"` element + // in this fixture already *is* a <div> (there is no other tag carrying + // that class) — so this exercises the tag+attrs combination the golden + // itself doesn't, without inventing an unverified result: same 2 items, + // same count. + name: 'find: tag + attrs (count/items)', + async run({ call }) { + const r = await call('browser::find', { html: MESSY_HTML, tag: 'div', attrs: { class: 'card' } }) + expectEqual( + r, + { + count: 2, + items: [ + { + tag: 'div', + text: 'Card One\nfirst card', + html: '<div class="card" data-id="1"><h3>Card One</h3><p>first card</p></div>', + attrs: { class: 'card', 'data-id': '1' }, + css: 'body > main > div', + xpath: '//body/main/div', + }, + { + tag: 'div', + text: 'Card Two\nsecond card', + html: '<div class="card" data-id="2"><h3>Card Two</h3><p>second card</p></div>', + attrs: { class: 'card', 'data-id': '2' }, + css: 'body > main > div:nth-of-type(2)', + xpath: '//body/main/div[2]', + }, + ], + }, + 'find tag+attrs', + ) + }, + }, + { + // Golden: tests/golden/behavior/find-by-text/exact_default.json + name: 'find-by-text: leading-text exact match', + async run({ call }) { + const r = await call('browser::find-by-text', { html: BASIC_HTML, text: 'Apple' }) + expectEqual( + r, + { + count: 1, + items: [ + { + tag: 'a', + text: 'Apple', + html: '<a href="/a">Apple</a>', + attrs: { href: '/a' }, + css: 'body > ul > li > a', + xpath: '//body/ul/li/a', + }, + ], + }, + 'find-by-text exact_default', + ) + }, + }, + { + // Golden: tests/golden/behavior/find-by-regex/default_insensitive.json + name: 'find-by-regex: leading-text pattern match (case-insensitive default)', + async run({ call }) { + const r = await call('browser::find-by-regex', { html: BASIC_HTML, pattern: 'price \\d+' }) + expectEqual( + r, + { + count: 1, + items: [ + { + tag: 'p', + text: 'price 42 usd then 99', + html: '<p>price 42 usd then 99</p>', + attrs: {}, + css: 'body > p', + xpath: '//body/p', + }, + ], + }, + 'find-by-regex default_insensitive', + ) + }, + }, + { + // Golden: tests/golden/behavior/find-similar/list_items.json + name: 'find-similar: li anchor (count 2)', + async run({ call }) { + const r = await call('browser::find-similar', { html: BASIC_HTML, anchor: 'li' }) + expectEqual( + r, + { + count: 2, + items: [ + { text: 'Apple', html: '<li><a href="/a">Apple</a></li>' }, + { text: 'Banana', html: '<li><a href="/b">Banana</a></li>' }, + ], + }, + 'find-similar list_items', + ) + }, + }, + { + // Golden: tests/golden/behavior/describe/h1_css.json + name: 'describe: h1 (found, classes, parent_tag)', + async run({ call }) { + const r = await call('browser::describe', { html: BASIC_HTML, query: 'h1' }) + expectEqual( + r, + { + found: true, + element: { + tag: 'h1', + text: 'Hello', + html: '<h1 class="t">Hello</h1>', + attrs: { class: 't' }, + css: 'body > h1', + xpath: '//body/h1', + full_css: 'body > h1', + full_xpath: '//body/h1', + classes: ['t'], + parent_tag: 'body', + children: 0, + siblings: 2, + }, + }, + 'describe h1_css', + ) + }, + }, + { + // Golden: tests/golden/behavior/to-markdown/text_basic.json — deterministic + // (no HTML->Markdown formatting choices in text mode), so an exact string. + name: 'to-markdown: text mode (exact string)', + async run({ call }) { + const r = await call('browser::to-markdown', { html: BASIC_HTML, format: 'text' }) + expectEqual(r, { format: 'text', content: 'Hello\nApple\nBanana\nprice 42 usd then 99' }, 'to-markdown text_basic') + }, + }, + { + name: 'css: adaptive direct match', + async run({ call }) { + expectEqual( + await call('browser::css', { + html: '<p>x</p>', + query: 'p', + adaptive: true, + auto_save: false, + adaptive_domain: 'e2e.invalid', + identifier: 'e2e-direct', + }), + { result: ['x'] }, + 'adaptive direct match', + ) + }, + }, + { + // Golden: tests/golden/behavior/css/invalid_selector.json, plus the exact + // iii bus error envelope observed by callers. + name: 'css: invalid selector error', + async run({ call }) { + await expectError( + () => call('browser::css', { html: BASIC_HTML, query: 'li:::bad' }), + "handler error: Invalid CSS selector 'li:::bad': Expected ident, got <DELIM ':' at 4>", + 'invalid css selector', + ) + }, + }, + { + // Golden: xpath/ancestor_axis_reverse_position.json. + name: 'xpath: ancestor axis reverse position', + async run({ call }) { + expectEqual( + await call('browser::xpath', { html: ANCESTOR_HTML, query: '//p/ancestor::*[1]/@id' }), + { result: ['s'] }, + 'ancestor axis', + ) + }, + }, + { + // Golden: tests/golden/behavior/find/negative_limit_empty.json uses + // limit=-1 on this exact html+tag ("a" on BASIC_HTML — same fixture + // find/by_tag.json proves has 2 matches with no limit) and gets + // count=2, items=[]. src/functions/common.rs::bounded clamps limit to + // `[0, MAX_FIND_ITEMS]` — `l.clamp(0, MAX)` maps BOTH -1 and 0 to the + // same ceiling of 0 — so limit=0 is the same code path the golden + // already covers, just via the other input that clamps to it. + name: 'find: limit 0 clamps items to [], count stays the true total', + async run({ call }) { + const r = await call('browser::find', { html: BASIC_HTML, tag: 'a', limit: 0 }) + expectEqual(r, { count: 2, items: [] }, 'find limit_clamp') + }, + }, + + // ---- outbound surface ------------------------------------------------- + // Every successful outbound case uses the harness-owned loopback origin. + // External egress remains unnecessary and SSRF policy is still exercised. + { + // The SSRF guard is the reason these functions can take an arbitrary + // caller-supplied URL at all, so it gets an end-to-end assertion, not + // just a unit test: 169.254.169.254 is the cloud metadata endpoint. + name: 'fetch: SSRF guard refuses the link-local metadata address', + async run({ call }) { + await expectError( + () => call('browser::fetch', { url: 'http://169.254.169.254/latest/meta-data/' }), + 'handler error: address 169.254.169.254 is in link-local (incl. AWS metadata)', + 'ssrf link-local', + ) + }, + }, + { + name: 'fetch: non-http scheme refused', + async run({ call }) { + await expectError( + () => call('browser::fetch', { url: 'file:///etc/passwd' }), + 'handler error: scheme not allowed: file: (only http: and https: are permitted)', + 'ssrf scheme', + ) + }, + }, + { + name: 'fetch: unsupported method named in the error', + async run({ call }) { + await expectError( + () => call('browser::fetch', { url: 'https://example.com/', method: 'patch' }), + 'handler error: unsupported method: patch', + 'fetch method', + ) + }, + }, + { + name: 'fetch: neither url nor urls', + async run({ call }) { + await expectError( + () => call('browser::fetch', {}), + 'handler error: provide `url` or `urls`', + 'fetch no target', + ) + }, + }, + { + name: 'browser fetchers: dynamic and stealthy render the local page', + async run({ call, origin }) { + for (const [functionId, viewport] of [ + ['browser::dynamic-fetch', '1280|720'], + ['browser::stealthy-fetch', '1920|1080'], + ] as const) { + const result = await call(functionId, { + url: `${origin}/page`, + include_html: true, + locale: 'fr-FR', + timezone_id: 'Europe/Paris', + retries: 1, + timeout: 5000, + solve_cloudflare: functionId === 'browser::stealthy-fetch', + }) + expectEqual(result.status, 200, `${functionId} status`) + expectEqual(result.url, `${origin}/page`, `${functionId} url`) + expectEqual(result.cookies, {}, `${functionId} cookie quirk`) + expectEqual(result.encoding, 'utf-8', `${functionId} encoding`) + expect(result.html.includes('<h1>rendered</h1>'), `${functionId} did not execute page script`) + expect( + result.html.includes(`fr-FR|Europe/Paris|2|${viewport}`), + `${functionId} fingerprint did not match the frozen viewport`, + ) + } + }, + }, + { + name: 'screenshot-url: unknown fetcher preserves the dynamic fallback quirk', + async run({ call, origin }) { + const result = await call('browser::screenshot-url', { + url: `${origin}/page`, + fetcher: 'telepathy', + format: 'png', + retries: 1, + timeout: 5000, + }) + expectEqual(result.mime, 'image/png', 'screenshot mime') + expectEqual(result.url, `${origin}/page`, 'screenshot url') + expectEqual(result.content.length, 2, 'screenshot content blocks') + expectEqual(result.content[0].type, 'image', 'screenshot image block') + expectEqual(result.content[0].mime, 'image/png', 'screenshot image block mime') + expect( + Buffer.from(result.content[0].data, 'base64').subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])), + 'screenshot is not a PNG', + ) + expectEqual(result.content[1].type, 'text', 'screenshot caption block') + expect( + result.content[1].text.startsWith(`screenshot of ${origin}/page — 1024x576px, 1 tile(s), `), + `unexpected screenshot caption: ${result.content[1].text}`, + ) + }, + }, + { + name: 'crawl: missing start_urls', + async run({ call }) { + await expectError( + () => call('browser::crawl', {}), + 'handler error: provide `start_urls`', + 'crawl no seeds', + ) + }, + }, + { + name: 'session: HTTP cookie state, list order, and idempotent close', + async run({ call, origin }) { + const opened = await call('browser::session-open', { type: 'http' }) + if (typeof opened.session_id !== 'string' || !/^[0-9a-f]{32}$/.test(opened.session_id)) { + throw new Error(`expected a UUID4 hex http session id, got ${JSON.stringify(opened)}`) + } + expectEqual(opened.type, 'http', 'session-open type') + + const listed = await call('browser::session-list', { type: 'http' }) + const mine = (listed.sessions ?? []).find((s: any) => s.session_id === opened.session_id) + if (!mine) throw new Error(`session-list omitted ${opened.session_id}: ${JSON.stringify(listed)}`) + for (const k of ['session_id', 'type', 'created_at', 'last_used', 'idle_s']) { + if (!(k in mine)) throw new Error(`session-list entry missing '${k}': ${JSON.stringify(mine)}`) + } + + const first = await call('browser::session-fetch', { + session_id: opened.session_id, + url: `${origin}/cookie`, + include_html: true, + retries: 1, + }) + const second = await call('browser::session-fetch', { + session_id: opened.session_id, + url: `${origin}/cookie`, + include_html: true, + retries: 1, + }) + expect(first.html.includes('<p>none</p>'), `first HTTP session fetch unexpectedly had a cookie: ${first.html}`) + expect(second.html.includes('<p>sid=abc</p>'), `HTTP session did not retain its cookie: ${second.html}`) + + expectEqual( + await call('browser::session-close', { session_id: opened.session_id }), + { closed: true }, + 'session-close first', + ) + expectEqual( + await call('browser::session-close', { session_id: opened.session_id }), + { closed: false }, + 'session-close idempotent', + ) + }, + }, + { + // Safe mode refuses caller proxies before dialing: their own DNS/routing + // could bypass the address pinned by the native egress policy. + name: 'fetch: safe mode refuses a caller proxy', + async run({ call }) { + await expectError( + () => + call('browser::fetch', { + url: 'https://example.com/', + proxy: 'http://169.254.169.254:3128', + }), + 'handler error: safe mode refuses `proxy`: a caller proxy can resolve or route to addresses outside the egress policy; use a certified compat build or remove the option', + 'safe proxy policy', + ) + }, + }, + { + name: 'fetch: safe HTTP happy path returns the frozen envelope', + async run({ call, origin }) { + const result = await call('browser::fetch', { + url: `${origin}/page`, + include_html: true, + timeout: 1e20, + retries: 1, + }) + expectEqual(result.status, 200, 'HTTP status') + expectEqual(result.url, `${origin}/page`, 'HTTP url') + expectEqual(result.cookies, { sid: 'abc' }, 'HTTP cookies') + expectEqual(result.encoding, 'utf-8', 'HTTP encoding') + expectEqual(result.html, ORIGIN_PAGE_HTML.replace('<!doctype html>', ''), 'HTTP normalized HTML') + }, + }, + { + name: 'crawl: local HTTP completion, sample, and one-based stream metadata', + async run({ call, origin }) { + const result = await call('browser::crawl', { + url: `${origin}/page`, + fetcher: 'http', + max_pages: 2, + max_depth: 1, + concurrency: 1, + group_id: 'e2e-crawl', + selectors: [{ name: 'heading', css: 'h1' }], + }) + expectEqual( + result, + { + stats: { crawled: 2, items: 2, errors: 0, stopped: 'done' }, + items: [ + { url: `${origin}/page`, status: 200, extracted: { heading: 'initial' } }, + { url: `${origin}/leaf`, status: 200, extracted: { heading: null } }, + ], + stream: { name: 'browser::crawl', group_id: 'e2e-crawl' }, + }, + 'crawl result', + ) + }, + }, + { + name: 'session: dynamic and stealthy browser backends fetch and close', + async run({ call, origin }) { + for (const type of ['dynamic', 'stealthy'] as const) { + const opened = await call('browser::session-open', { + type, + locale: 'fr-FR', + timezone_id: 'Europe/Paris', + solve_cloudflare: type === 'stealthy', + }) + expectEqual(opened.type, type, `${type} session type`) + expect(/^[0-9a-f]{32}$/.test(opened.session_id), `${type} session id is not UUID4 hex`) + const fetched = await call('browser::session-fetch', { + session_id: opened.session_id, + url: `${origin}/page`, + include_html: true, + retries: 1, + timeout: 5000, + }) + expectEqual(fetched.status, 200, `${type} session status`) + expect(fetched.html.includes('<h1>rendered</h1>'), `${type} session did not render`) + expectEqual(await call('browser::session-close', { session_id: opened.session_id }), { closed: true }, `${type} close`) + } + }, + }, + { + name: 'session-fetch: unknown session id', + async run({ call }) { + await expectError( + () => call('browser::session-fetch', { session_id: 'b999', url: 'https://example.com/' }), + 'handler error: unknown session: b999', + 'session unknown', + ) + }, + }, +] diff --git a/browser/tests/e2e/workers/harness/src/runner.ts b/browser/tests/e2e/workers/harness/src/runner.ts new file mode 100644 index 000000000..5f1de8185 --- /dev/null +++ b/browser/tests/e2e/workers/harness/src/runner.ts @@ -0,0 +1,123 @@ +import { writeFileSync, mkdirSync } from 'node:fs' +import { createServer, type Server } from 'node:http' +import { resolve } from 'node:path' +import { once } from 'node:events' +import type { ISdk } from 'iii-sdk' +import { CASES, ORIGIN_PAGE_HTML, type CaseContext, type TestCase } from './cases.ts' + +interface CaseResult { + case: string + status: 'PASS' | 'FAIL' + error?: string + duration_ms: number +} + +export interface RunnerOptions { + iii: ISdk + reportPath: string + /** Harness-side substring filter on case name (run-tests.sh's --filter=). */ + filter?: string +} + +export class Runner { + private origin = '' + + constructor(private opts: RunnerOptions) {} + + private async call(functionId: string, payload: unknown): Promise<any> { + return await this.opts.iii.trigger<unknown, any>({ function_id: functionId, payload }) + } + + private async callWithRetry(functionId: string, payload: unknown, attempts = 10): Promise<any> { + let lastErr: unknown + for (let i = 0; i < attempts; i++) { + try { + return await this.call(functionId, payload) + } catch (e) { + lastErr = e + await new Promise((r) => setTimeout(r, 200)) + } + } + throw lastErr + } + + private async runCase(c: TestCase): Promise<CaseResult> { + const start = Date.now() + const ctx: CaseContext = { call: (id, payload) => this.call(id, payload), origin: this.origin } + try { + await c.run(ctx) + return { case: c.name, status: 'PASS', duration_ms: Date.now() - start } + } catch (e: any) { + return { case: c.name, status: 'FAIL', error: e?.message ?? String(e), duration_ms: Date.now() - start } + } + } + + async runAll(): Promise<{ pass: number; total: number; results: CaseResult[] }> { + // Probe with a no-op call until it succeeds; tolerates the worker-startup + // race (run-tests.sh already retries `iii trigger` before launching the + // harness at all, but the harness is also runnable standalone). + await this.callWithRetry('browser::css', { html: '<p>x</p>', query: 'p' }) + + const server = await this.startOrigin() + const cases = this.opts.filter ? CASES.filter((c) => c.name.includes(this.opts.filter!)) : CASES + + // Stream each case result to stdout as it completes, colored green/red + // only when stdout is a TTY — run-tests.sh redirects stdout to a log + // file, and bash's grep for the HARNESS_DONE sentinel must see plain text. + const useColor = process.stdout.isTTY === true + const GREEN = useColor ? '\x1b[32m' : '' + const RED = useColor ? '\x1b[31m' : '' + const RESET = useColor ? '\x1b[0m' : '' + + const results: CaseResult[] = [] + try { + for (const c of cases) { + const r = await this.runCase(c) + const color = r.status === 'PASS' ? GREEN : RED + const err = r.error ? ' — ' + r.error : '' + console.log(`[harness] ${color}${r.status}${RESET} ${r.case} (${r.duration_ms}ms)${err}`) + results.push(r) + } + } finally { + server.close() + } + + const pass = results.filter((r) => r.status === 'PASS').length + + mkdirSync(resolve(this.opts.reportPath, '..'), { recursive: true }) + writeFileSync(this.opts.reportPath, JSON.stringify({ pass, total: results.length, results }, null, 2)) + + return { pass, total: results.length, results } + } + + private async startOrigin(): Promise<Server> { + const server = createServer((req, res) => { + const path = new URL(req.url ?? '/', 'http://127.0.0.1').pathname + const body = + path === '/plain' + ? Buffer.from([0x63, 0x61, 0x66, 0xe9]) + : Buffer.from( + path === '/leaf' + ? '<html><body><p>leaf</p></body></html>' + : path === '/cookie' + ? `<html><body><p>${req.headers.cookie ?? 'none'}</p></body></html>` + : ORIGIN_PAGE_HTML, + ) + res.sendDate = false + res.statusCode = path === '/plain' ? 206 : 200 + res.setHeader('Content-Type', path === '/plain' ? 'text/plain; charset=iso-8859-1' : 'text/html; charset=utf-8') + res.setHeader('Date', 'Wed, 12 Aug 2026 16:00:00 GMT') + res.setHeader('X-Test', ['one', 'two']) + res.setHeader('Set-Cookie', 'sid=abc; Path=/') + res.setHeader('Content-Length', body.length) + res.setHeader('Connection', 'close') + res.end(body) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (!address || typeof address === 'string') throw new Error('local origin did not bind TCP') + this.origin = `http://127.0.0.1:${address.port}` + return server + } +} diff --git a/browser/tests/e2e/workers/harness/src/worker.ts b/browser/tests/e2e/workers/harness/src/worker.ts new file mode 100644 index 000000000..1981cd8b8 --- /dev/null +++ b/browser/tests/e2e/workers/harness/src/worker.ts @@ -0,0 +1,46 @@ +import { registerWorker, Logger } from 'iii-sdk' +import { resolve } from 'node:path' +import { Runner } from './runner.ts' + +const URL = process.env.III_URL ?? 'ws://127.0.0.1:49234' +const REPORT_PATH = resolve(process.env.HARNESS_REPORT_PATH ?? './reports/report.json') +const FILTER = process.env.HARNESS_FILTER + +const iii = registerWorker(URL) +const logger = new Logger() +const runner = new Runner({ iii, reportPath: REPORT_PATH, filter: FILTER }) + +logger.info('harness: registered, kicking off suite', { + url: URL, + filter: FILTER ?? 'all', + reportPath: REPORT_PATH, +}) + +;(async () => { + // ANSI colors only when stdout is a TTY — run-tests.sh redirects to a log + // file, and bash's grep for the HARNESS_DONE sentinel must see plain text. + const useColor = process.stdout.isTTY === true + const GREEN = useColor ? '\x1b[32m' : '' + const RED = useColor ? '\x1b[31m' : '' + const RESET = useColor ? '\x1b[0m' : '' + let exitCode = 1 + try { + const { pass, total } = await runner.runAll() + // total===0 is a FAIL, not a vacuous pass: a mistyped --filter that + // matches no cases would otherwise report green having run nothing. + const status = total > 0 && pass === total ? 'PASS' : 'FAIL' + const color = status === 'PASS' ? GREEN : RED + console.log(`HARNESS_DONE: ${color}${status}${RESET} ${pass}/${total}`) + exitCode = status === 'PASS' ? 0 : 1 + } catch (e: any) { + console.error('[harness] fatal:', e?.stack ?? e) + console.log(`HARNESS_DONE: ${RED}FAIL${RESET} 0/0`) + exitCode = 1 + } + // 200ms grace lets the OS flush ws bytes; iii.shutdown() then closes the ws + // and drains OTel queues. iii.shutdown() itself does NOT await the ws close + // handshake, hence the explicit delay. + await new Promise((r) => setTimeout(r, 200)) + await iii.shutdown() + process.exit(exitCode) +})() diff --git a/browser/tests/e2e/workers/harness/tsconfig.json b/browser/tests/e2e/workers/harness/tsconfig.json new file mode 100644 index 000000000..91c7e3885 --- /dev/null +++ b/browser/tests/e2e/workers/harness/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/browser/tests/golden/behavior/css/all_default.json b/browser/tests/golden/behavior/css/all_default.json new file mode 100644 index 000000000..0f32ae302 --- /dev/null +++ b/browser/tests/golden/behavior/css/all_default.json @@ -0,0 +1,14 @@ +{ + "function": "browser::css", + "case": "all_default", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "li a" + }, + "ok": { + "result": [ + "Apple", + "Banana" + ] + } +} diff --git a/browser/tests/golden/behavior/css/attr_miss_in_all.json b/browser/tests/golden/behavior/css/attr_miss_in_all.json new file mode 100644 index 000000000..b5363c4da --- /dev/null +++ b/browser/tests/golden/behavior/css/attr_miss_in_all.json @@ -0,0 +1,15 @@ +{ + "function": "browser::css", + "case": "attr_miss_in_all", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "li a", + "attr": "data-x" + }, + "ok": { + "result": [ + null, + null + ] + } +} diff --git a/browser/tests/golden/behavior/css/bare_detached_text.json b/browser/tests/golden/behavior/css/bare_detached_text.json new file mode 100644 index 000000000..07eb1842d --- /dev/null +++ b/browser/tests/golden/behavior/css/bare_detached_text.json @@ -0,0 +1,16 @@ +{ + "function": "browser::css", + "case": "bare_detached_text", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "::text" + }, + "ok": { + "result": [ + "Hello", + "Apple", + "Banana", + "price 42 usd then 99" + ] + } +} diff --git a/browser/tests/golden/behavior/css/detached_text_descendants.json b/browser/tests/golden/behavior/css/detached_text_descendants.json new file mode 100644 index 000000000..49641b7d3 --- /dev/null +++ b/browser/tests/golden/behavior/css/detached_text_descendants.json @@ -0,0 +1,14 @@ +{ + "function": "browser::css", + "case": "detached_text_descendants", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "li ::text" + }, + "ok": { + "result": [ + "Apple", + "Banana" + ] + } +} diff --git a/browser/tests/golden/behavior/css/detached_text_first.json b/browser/tests/golden/behavior/css/detached_text_first.json new file mode 100644 index 000000000..9c73a3c63 --- /dev/null +++ b/browser/tests/golden/behavior/css/detached_text_first.json @@ -0,0 +1,12 @@ +{ + "function": "browser::css", + "case": "detached_text_first", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "h1 ::text", + "first": true + }, + "ok": { + "result": "Hello" + } +} diff --git a/browser/tests/golden/behavior/css/empty_attr_falls_back_to_text.json b/browser/tests/golden/behavior/css/empty_attr_falls_back_to_text.json new file mode 100644 index 000000000..7560e3f3a --- /dev/null +++ b/browser/tests/golden/behavior/css/empty_attr_falls_back_to_text.json @@ -0,0 +1,13 @@ +{ + "function": "browser::css", + "case": "empty_attr_falls_back_to_text", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "li a", + "first": true, + "attr": "" + }, + "ok": { + "result": "Apple" + } +} diff --git a/browser/tests/golden/behavior/css/first_attr.json b/browser/tests/golden/behavior/css/first_attr.json new file mode 100644 index 000000000..6360529a0 --- /dev/null +++ b/browser/tests/golden/behavior/css/first_attr.json @@ -0,0 +1,13 @@ +{ + "function": "browser::css", + "case": "first_attr", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "li a", + "first": true, + "attr": "href" + }, + "ok": { + "result": "/a" + } +} diff --git a/browser/tests/golden/behavior/css/first_text.json b/browser/tests/golden/behavior/css/first_text.json new file mode 100644 index 000000000..4a39dd80d --- /dev/null +++ b/browser/tests/golden/behavior/css/first_text.json @@ -0,0 +1,12 @@ +{ + "function": "browser::css", + "case": "first_text", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "li a", + "first": true + }, + "ok": { + "result": "Apple" + } +} diff --git a/browser/tests/golden/behavior/css/general_sibling_attr_pseudo.json b/browser/tests/golden/behavior/css/general_sibling_attr_pseudo.json new file mode 100644 index 000000000..cfd871ace --- /dev/null +++ b/browser/tests/golden/behavior/css/general_sibling_attr_pseudo.json @@ -0,0 +1,14 @@ +{ + "function": "browser::css", + "case": "general_sibling_attr_pseudo", + "request": { + "html": "<h1>H</h1><p data-x='a'>A</p><p data-x='b'>B</p>", + "query": "h1 ~ p::attr(data-x)" + }, + "ok": { + "result": [ + "a", + "b" + ] + } +} diff --git a/browser/tests/golden/behavior/css/grouped_selector_document_order.json b/browser/tests/golden/behavior/css/grouped_selector_document_order.json new file mode 100644 index 000000000..69c16341d --- /dev/null +++ b/browser/tests/golden/behavior/css/grouped_selector_document_order.json @@ -0,0 +1,15 @@ +{ + "function": "browser::css", + "case": "grouped_selector_document_order", + "request": { + "html": "<p>P</p><h1>H</h1><p>Q</p>", + "query": "h1, p" + }, + "ok": { + "result": [ + "P", + "H", + "Q" + ] + } +} diff --git a/browser/tests/golden/behavior/css/invalid_selector.json b/browser/tests/golden/behavior/css/invalid_selector.json new file mode 100644 index 000000000..ab1dd266b --- /dev/null +++ b/browser/tests/golden/behavior/css/invalid_selector.json @@ -0,0 +1,9 @@ +{ + "function": "browser::css", + "case": "invalid_selector", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "li:::bad" + }, + "err": "Invalid CSS selector 'li:::bad': Expected ident, got <DELIM ':' at 4>" +} diff --git a/browser/tests/golden/behavior/css/no_match_first.json b/browser/tests/golden/behavior/css/no_match_first.json new file mode 100644 index 000000000..f2d81e1f3 --- /dev/null +++ b/browser/tests/golden/behavior/css/no_match_first.json @@ -0,0 +1,12 @@ +{ + "function": "browser::css", + "case": "no_match_first", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": ".nope", + "first": true + }, + "ok": { + "result": null + } +} diff --git a/browser/tests/golden/behavior/css/no_match_modes.json b/browser/tests/golden/behavior/css/no_match_modes.json new file mode 100644 index 000000000..55b9e57d5 --- /dev/null +++ b/browser/tests/golden/behavior/css/no_match_modes.json @@ -0,0 +1,11 @@ +{ + "function": "browser::css", + "case": "no_match_modes", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": ".nope" + }, + "ok": { + "result": [] + } +} diff --git a/browser/tests/golden/behavior/css/nth_not_and_attribute_operators.json b/browser/tests/golden/behavior/css/nth_not_and_attribute_operators.json new file mode 100644 index 000000000..6736c8d5d --- /dev/null +++ b/browser/tests/golden/behavior/css/nth_not_and_attribute_operators.json @@ -0,0 +1,12 @@ +{ + "function": "browser::css", + "case": "nth_not_and_attribute_operators", + "request": { + "html": "<ul><li class='x' data-v='en-us'>A</li><li data-v='en'>B</li><li data-v='fr'>C</li></ul>", + "query": "li:nth-child(2):not(.x)[data-v|='en']", + "first": true + }, + "ok": { + "result": "B" + } +} diff --git a/browser/tests/golden/behavior/css/pseudo_attr.json b/browser/tests/golden/behavior/css/pseudo_attr.json new file mode 100644 index 000000000..3d12b32d6 --- /dev/null +++ b/browser/tests/golden/behavior/css/pseudo_attr.json @@ -0,0 +1,14 @@ +{ + "function": "browser::css", + "case": "pseudo_attr", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "a::attr(href)" + }, + "ok": { + "result": [ + "/a", + "/b" + ] + } +} diff --git a/browser/tests/golden/behavior/css/pseudo_text.json b/browser/tests/golden/behavior/css/pseudo_text.json new file mode 100644 index 000000000..24898922e --- /dev/null +++ b/browser/tests/golden/behavior/css/pseudo_text.json @@ -0,0 +1,12 @@ +{ + "function": "browser::css", + "case": "pseudo_text", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "h1::text", + "first": true + }, + "ok": { + "result": "Hello" + } +} diff --git a/browser/tests/golden/behavior/css/sibling_text_pseudo.json b/browser/tests/golden/behavior/css/sibling_text_pseudo.json new file mode 100644 index 000000000..e4a28f8f3 --- /dev/null +++ b/browser/tests/golden/behavior/css/sibling_text_pseudo.json @@ -0,0 +1,14 @@ +{ + "function": "browser::css", + "case": "sibling_text_pseudo", + "request": { + "html": "<h1>H</h1>x<p>A<b>B</b>C</p><p>D</p>", + "query": "h1 + p::text" + }, + "ok": { + "result": [ + "A", + "C" + ] + } +} diff --git a/browser/tests/golden/behavior/css/template_child_css.json b/browser/tests/golden/behavior/css/template_child_css.json new file mode 100644 index 000000000..306a08768 --- /dev/null +++ b/browser/tests/golden/behavior/css/template_child_css.json @@ -0,0 +1,12 @@ +{ + "function": "browser::css", + "case": "template_child_css", + "request": { + "html": "<html><head><title>Messy Page</title><meta charset=\"utf-8\"></head><body>\n<nav><ul><li><a href=\"/home\">Home</a></li><li><a href=\"/about\">About</a></li></ul></nav>\n<template><p>never render this</p></template>\n<div aria-hidden=\"true\">screen-reader trap</div>\n<div style=\"display:none\">hidden A</div>\n<div style=\"visibility: hidden\">hidden B</div>\n<main>\n <h1>Widget Review</h1>\n <p>Intro paragraph with <em>emphasis</em> and a <a href=\"/w/1\">link</a>.</p>\n <h2>Specs</h2>\n <table><tr><th>Name</th><th>Value</th></tr><tr><td>Weight</td><td>3kg</td></tr></table>\n <ul><li>alpha<ul><li>nested</li></ul></li><li>beta</li></ul>\n <div class=\"card\" data-id=\"1\"><h3>Card One</h3><p>first card</p></div>\n <div class=\"card\" data-id=\"2\"><h3>Card Two</h3><p>second card</p></div>\n <div class=\"card wide\" data-id=\"3\"><h3>Card Three</h3><p>third card</p></div>\n</main>\n<footer><p>© 2026 Example — <span style=\"font-size:0\">invisible</span>fine print</p></footer>\n<script>analytics()</script>\n</body></html>\n", + "query": "template > p", + "first": true + }, + "ok": { + "result": "never render this" + } +} diff --git a/browser/tests/golden/behavior/css/text_pseudo_messy_main.json b/browser/tests/golden/behavior/css/text_pseudo_messy_main.json new file mode 100644 index 000000000..e4182b130 --- /dev/null +++ b/browser/tests/golden/behavior/css/text_pseudo_messy_main.json @@ -0,0 +1,18 @@ +{ + "function": "browser::css", + "case": "text_pseudo_messy_main", + "request": { + "html": "<html><head><title>Messy Page</title><meta charset=\"utf-8\"></head><body>\n<nav><ul><li><a href=\"/home\">Home</a></li><li><a href=\"/about\">About</a></li></ul></nav>\n<template><p>never render this</p></template>\n<div aria-hidden=\"true\">screen-reader trap</div>\n<div style=\"display:none\">hidden A</div>\n<div style=\"visibility: hidden\">hidden B</div>\n<main>\n <h1>Widget Review</h1>\n <p>Intro paragraph with <em>emphasis</em> and a <a href=\"/w/1\">link</a>.</p>\n <h2>Specs</h2>\n <table><tr><th>Name</th><th>Value</th></tr><tr><td>Weight</td><td>3kg</td></tr></table>\n <ul><li>alpha<ul><li>nested</li></ul></li><li>beta</li></ul>\n <div class=\"card\" data-id=\"1\"><h3>Card One</h3><p>first card</p></div>\n <div class=\"card\" data-id=\"2\"><h3>Card Two</h3><p>second card</p></div>\n <div class=\"card wide\" data-id=\"3\"><h3>Card Three</h3><p>third card</p></div>\n</main>\n<footer><p>© 2026 Example — <span style=\"font-size:0\">invisible</span>fine print</p></footer>\n<script>analytics()</script>\n</body></html>\n", + "query": "main::text" + }, + "ok": { + "result": [ + "\n ", + "\n ", + "\n ", + "\n ", + "\n ", + "\n" + ] + } +} diff --git a/browser/tests/golden/behavior/describe/h1_css.json b/browser/tests/golden/behavior/describe/h1_css.json new file mode 100644 index 000000000..fbf67bd02 --- /dev/null +++ b/browser/tests/golden/behavior/describe/h1_css.json @@ -0,0 +1,29 @@ +{ + "function": "browser::describe", + "case": "h1_css", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "h1" + }, + "ok": { + "found": true, + "element": { + "tag": "h1", + "text": "Hello", + "html": "<h1 class=\"t\">Hello</h1>", + "attrs": { + "class": "t" + }, + "css": "body > h1", + "xpath": "//body/h1", + "full_css": "body > h1", + "full_xpath": "//body/h1", + "classes": [ + "t" + ], + "parent_tag": "body", + "children": 0, + "siblings": 2 + } + } +} diff --git a/browser/tests/golden/behavior/describe/id_shortcircuit_full.json b/browser/tests/golden/behavior/describe/id_shortcircuit_full.json new file mode 100644 index 000000000..815bd3090 --- /dev/null +++ b/browser/tests/golden/behavior/describe/id_shortcircuit_full.json @@ -0,0 +1,25 @@ +{ + "function": "browser::describe", + "case": "id_shortcircuit_full", + "request": { + "html": "<html><head></head><body>\n<div id=\"wrap\" class=\"outer main\">\n <span>CONDITION: <!-- separator -->Excellent</span>\n <p> <b>bold</b>after-bold</p>\n <p>lead<b>mid</b>tail</p>\n <ul><li>one</li><li>two</li><li>three</li></ul>\n <a href=\"/x?a=1&amp;b=2\" data-price=\"10\">A&amp;B</a>\n <input disabled type=\"text\">\n <script>var hidden = \"never\";</script>\n <style>.x { display: none; }</style>\n <p class=\"uni\">Ünïcode — café</p>\n <textarea> </textarea>\n <div>line one\nand two <b>x</b></div>\n <div data-x=\"1\">line oneand two <b>y</b></div>\n</div>\n</body></html>\n", + "query": "#wrap p" + }, + "ok": { + "found": true, + "element": { + "tag": "p", + "text": "bold\nafter-bold", + "html": "<p> <b>bold</b>after-bold</p>", + "attrs": {}, + "css": "#wrap > p", + "xpath": "//*[@id='wrap']/p", + "full_css": "body > #wrap > p", + "full_xpath": "//body/*[@id='wrap']/p", + "classes": [], + "parent_tag": "div", + "children": 1, + "siblings": 11 + } + } +} diff --git a/browser/tests/golden/behavior/describe/no_match.json b/browser/tests/golden/behavior/describe/no_match.json new file mode 100644 index 000000000..25a36ad12 --- /dev/null +++ b/browser/tests/golden/behavior/describe/no_match.json @@ -0,0 +1,11 @@ +{ + "function": "browser::describe", + "case": "no_match", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": ".nope" + }, + "ok": { + "found": false + } +} diff --git a/browser/tests/golden/behavior/describe/text_pseudo.json b/browser/tests/golden/behavior/describe/text_pseudo.json new file mode 100644 index 000000000..47b157ae2 --- /dev/null +++ b/browser/tests/golden/behavior/describe/text_pseudo.json @@ -0,0 +1,25 @@ +{ + "function": "browser::describe", + "case": "text_pseudo", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "h1::text" + }, + "ok": { + "found": true, + "element": { + "tag": "#text", + "text": "Hello", + "html": "Hello", + "attrs": {}, + "css": "", + "xpath": "", + "full_css": "", + "full_xpath": "", + "classes": [], + "parent_tag": "h1", + "children": 0, + "siblings": 0 + } + } +} diff --git a/browser/tests/golden/behavior/describe/weird_kind_is_xpath.json b/browser/tests/golden/behavior/describe/weird_kind_is_xpath.json new file mode 100644 index 000000000..1f720c25d --- /dev/null +++ b/browser/tests/golden/behavior/describe/weird_kind_is_xpath.json @@ -0,0 +1,30 @@ +{ + "function": "browser::describe", + "case": "weird_kind_is_xpath", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "//h1", + "kind": "bogus" + }, + "ok": { + "found": true, + "element": { + "tag": "h1", + "text": "Hello", + "html": "<h1 class=\"t\">Hello</h1>", + "attrs": { + "class": "t" + }, + "css": "body > h1", + "xpath": "//body/h1", + "full_css": "body > h1", + "full_xpath": "//body/h1", + "classes": [ + "t" + ], + "parent_tag": "body", + "children": 0, + "siblings": 2 + } + } +} diff --git a/browser/tests/golden/behavior/describe/xpath_kind.json b/browser/tests/golden/behavior/describe/xpath_kind.json new file mode 100644 index 000000000..5b5ec9fea --- /dev/null +++ b/browser/tests/golden/behavior/describe/xpath_kind.json @@ -0,0 +1,28 @@ +{ + "function": "browser::describe", + "case": "xpath_kind", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "//li[2]/a", + "kind": "xpath" + }, + "ok": { + "found": true, + "element": { + "tag": "a", + "text": "Banana", + "html": "<a href=\"/b\">Banana</a>", + "attrs": { + "href": "/b" + }, + "css": "body > ul > li:nth-of-type(2) > a", + "xpath": "//body/ul/li[2]/a", + "full_css": "body > ul > li:nth-of-type(2) > a", + "full_xpath": "//body/ul/li[2]/a", + "classes": [], + "parent_tag": "li", + "children": 0, + "siblings": 0 + } + } +} diff --git a/browser/tests/golden/behavior/extract/comments_removed_and_text_merged.json b/browser/tests/golden/behavior/extract/comments_removed_and_text_merged.json new file mode 100644 index 000000000..98c1277e6 --- /dev/null +++ b/browser/tests/golden/behavior/extract/comments_removed_and_text_merged.json @@ -0,0 +1,27 @@ +{ + "function": "browser::extract", + "case": "comments_removed_and_text_merged", + "request": { + "html": "<p>a<!--gone-->b<![CDATA[c]]>d</p>", + "selectors": [ + { + "name": "text", + "xpath": "//p/text()", + "all": true + }, + { + "name": "html", + "css": "p", + "html": true + } + ] + }, + "ok": { + "extracted": { + "text": [ + "abd" + ], + "html": "<p>abd</p>" + } + } +} diff --git a/browser/tests/golden/behavior/extract/detached_text_spec.json b/browser/tests/golden/behavior/extract/detached_text_spec.json new file mode 100644 index 000000000..c30696511 --- /dev/null +++ b/browser/tests/golden/behavior/extract/detached_text_spec.json @@ -0,0 +1,26 @@ +{ + "function": "browser::extract", + "case": "detached_text_spec", + "request": { + "html": "<html><head><title>Messy Page</title><meta charset=\"utf-8\"></head><body>\n<nav><ul><li><a href=\"/home\">Home</a></li><li><a href=\"/about\">About</a></li></ul></nav>\n<template><p>never render this</p></template>\n<div aria-hidden=\"true\">screen-reader trap</div>\n<div style=\"display:none\">hidden A</div>\n<div style=\"visibility: hidden\">hidden B</div>\n<main>\n <h1>Widget Review</h1>\n <p>Intro paragraph with <em>emphasis</em> and a <a href=\"/w/1\">link</a>.</p>\n <h2>Specs</h2>\n <table><tr><th>Name</th><th>Value</th></tr><tr><td>Weight</td><td>3kg</td></tr></table>\n <ul><li>alpha<ul><li>nested</li></ul></li><li>beta</li></ul>\n <div class=\"card\" data-id=\"1\"><h3>Card One</h3><p>first card</p></div>\n <div class=\"card\" data-id=\"2\"><h3>Card Two</h3><p>second card</p></div>\n <div class=\"card wide\" data-id=\"3\"><h3>Card Three</h3><p>third card</p></div>\n</main>\n<footer><p>© 2026 Example — <span style=\"font-size:0\">invisible</span>fine print</p></footer>\n<script>analytics()</script>\n</body></html>\n", + "selectors": [ + { + "name": "card_text", + "css": "div.card ::text", + "all": true + } + ] + }, + "ok": { + "extracted": { + "card_text": [ + "Card One", + "first card", + "Card Two", + "second card", + "Card Three", + "third card" + ] + } + } +} diff --git a/browser/tests/golden/behavior/extract/empty_selectors.json b/browser/tests/golden/behavior/extract/empty_selectors.json new file mode 100644 index 000000000..427498274 --- /dev/null +++ b/browser/tests/golden/behavior/extract/empty_selectors.json @@ -0,0 +1,11 @@ +{ + "function": "browser::extract", + "case": "empty_selectors", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "selectors": [] + }, + "ok": { + "extracted": {} + } +} diff --git a/browser/tests/golden/behavior/extract/entities_and_invalid_codepoints.json b/browser/tests/golden/behavior/extract/entities_and_invalid_codepoints.json new file mode 100644 index 000000000..620d1ff6e --- /dev/null +++ b/browser/tests/golden/behavior/extract/entities_and_invalid_codepoints.json @@ -0,0 +1,27 @@ +{ + "function": "browser::extract", + "case": "entities_and_invalid_codepoints", + "request": { + "html": "<p>&copy; &apos; &#0; &#xD800; &notanentity;</p>", + "selectors": [ + { + "name": "text", + "xpath": "//p/text()", + "all": true + }, + { + "name": "html", + "css": "p", + "html": true + } + ] + }, + "ok": { + "extracted": { + "text": [ + "© ' � � ¬anentity;" + ], + "html": "<p>© ' � � ¬anentity;</p>" + } + } +} diff --git a/browser/tests/golden/behavior/extract/foreign_content_serialization.json b/browser/tests/golden/behavior/extract/foreign_content_serialization.json new file mode 100644 index 000000000..6f63191c4 --- /dev/null +++ b/browser/tests/golden/behavior/extract/foreign_content_serialization.json @@ -0,0 +1,19 @@ +{ + "function": "browser::extract", + "case": "foreign_content_serialization", + "request": { + "html": "<svg viewBox='0 0 1 1'><foreignObject><DIV xlink:href='x'>T</DIV></foreignObject></svg>", + "selectors": [ + { + "name": "svg", + "css": "svg", + "html": true + } + ] + }, + "ok": { + "extracted": { + "svg": "<svg viewbox=\"0 0 1 1\"><foreignobject><div xlink:href=\"x\">T</div></foreignobject></svg>" + } + } +} diff --git a/browser/tests/golden/behavior/extract/malformed_table_recovery.json b/browser/tests/golden/behavior/extract/malformed_table_recovery.json new file mode 100644 index 000000000..44ccb099a --- /dev/null +++ b/browser/tests/golden/behavior/extract/malformed_table_recovery.json @@ -0,0 +1,19 @@ +{ + "function": "browser::extract", + "case": "malformed_table_recovery", + "request": { + "html": "<table><td>A<td>B<div>C", + "selectors": [ + { + "name": "table", + "css": "table", + "html": true + } + ] + }, + "ok": { + "extracted": { + "table": "<table><td>A</td><td>B<div>C</div></td></table>" + } + } +} diff --git a/browser/tests/golden/behavior/extract/misnested_formatting_recovery.json b/browser/tests/golden/behavior/extract/misnested_formatting_recovery.json new file mode 100644 index 000000000..548992e46 --- /dev/null +++ b/browser/tests/golden/behavior/extract/misnested_formatting_recovery.json @@ -0,0 +1,19 @@ +{ + "function": "browser::extract", + "case": "misnested_formatting_recovery", + "request": { + "html": "<p><b>one<i>two</b>three</i>tail", + "selectors": [ + { + "name": "body", + "css": "body", + "html": true + } + ] + }, + "ok": { + "extracted": { + "body": "<body><p><b>one<i>two</i></b>threetail</p></body>" + } + } +} diff --git a/browser/tests/golden/behavior/extract/mixed_specs.json b/browser/tests/golden/behavior/extract/mixed_specs.json new file mode 100644 index 000000000..1658d1da3 --- /dev/null +++ b/browser/tests/golden/behavior/extract/mixed_specs.json @@ -0,0 +1,48 @@ +{ + "function": "browser::extract", + "case": "mixed_specs", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "selectors": [ + { + "name": "title", + "css": "h1" + }, + { + "name": "links", + "css": "li a", + "attr": "href", + "all": true + }, + { + "name": "names", + "css": "li a", + "all": true + }, + { + "name": "price", + "regex": "price (\\d+)" + }, + { + "name": "first_li_html", + "css": "li", + "html": true + } + ] + }, + "ok": { + "extracted": { + "title": "Hello", + "links": [ + "/a", + "/b" + ], + "names": [ + "Apple", + "Banana" + ], + "price": "42", + "first_li_html": "<li><a href=\"/a\">Apple</a></li>" + } + } +} diff --git a/browser/tests/golden/behavior/extract/regex_all_spec.json b/browser/tests/golden/behavior/extract/regex_all_spec.json new file mode 100644 index 000000000..8692732eb --- /dev/null +++ b/browser/tests/golden/behavior/extract/regex_all_spec.json @@ -0,0 +1,22 @@ +{ + "function": "browser::extract", + "case": "regex_all_spec", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "selectors": [ + { + "name": "nums", + "regex": "\\d+", + "all": true + } + ] + }, + "ok": { + "extracted": { + "nums": [ + "42", + "99" + ] + } + } +} diff --git a/browser/tests/golden/behavior/extract/spec_without_query.json b/browser/tests/golden/behavior/extract/spec_without_query.json new file mode 100644 index 000000000..f056a5bd0 --- /dev/null +++ b/browser/tests/golden/behavior/extract/spec_without_query.json @@ -0,0 +1,22 @@ +{ + "function": "browser::extract", + "case": "spec_without_query", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "selectors": [ + { + "name": "x" + }, + { + "name": "y", + "all": true + } + ] + }, + "ok": { + "extracted": { + "x": null, + "y": [] + } + } +} diff --git a/browser/tests/golden/behavior/extract/template_nested_content.json b/browser/tests/golden/behavior/extract/template_nested_content.json new file mode 100644 index 000000000..cfca0fcfd --- /dev/null +++ b/browser/tests/golden/behavior/extract/template_nested_content.json @@ -0,0 +1,27 @@ +{ + "function": "browser::extract", + "case": "template_nested_content", + "request": { + "html": "<template><table><td>T</template><p>P", + "selectors": [ + { + "name": "template", + "css": "template", + "html": true + }, + { + "name": "td", + "xpath": "//template//td", + "all": true + } + ] + }, + "ok": { + "extracted": { + "template": "<template><table><td>T<p>P</p></td></table></template>", + "td": [ + "T\nP" + ] + } + } +} diff --git a/browser/tests/golden/behavior/extract/xpath_specs.json b/browser/tests/golden/behavior/extract/xpath_specs.json new file mode 100644 index 000000000..1734f5a89 --- /dev/null +++ b/browser/tests/golden/behavior/extract/xpath_specs.json @@ -0,0 +1,28 @@ +{ + "function": "browser::extract", + "case": "xpath_specs", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "selectors": [ + { + "name": "first_link", + "xpath": "//ul/li/a", + "attr": "href" + }, + { + "name": "all_text", + "xpath": "//li/a/text()", + "all": true + } + ] + }, + "ok": { + "extracted": { + "first_link": "/a", + "all_text": [ + "Apple", + "Banana" + ] + } + } +} diff --git a/browser/tests/golden/behavior/find-by-regex/default_insensitive.json b/browser/tests/golden/behavior/find-by-regex/default_insensitive.json new file mode 100644 index 000000000..3d339f780 --- /dev/null +++ b/browser/tests/golden/behavior/find-by-regex/default_insensitive.json @@ -0,0 +1,21 @@ +{ + "function": "browser::find-by-regex", + "case": "default_insensitive", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "pattern": "price \\d+" + }, + "ok": { + "count": 1, + "items": [ + { + "tag": "p", + "text": "price 42 usd then 99", + "html": "<p>price 42 usd then 99</p>", + "attrs": {}, + "css": "body > p", + "xpath": "//body/p" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find-by-regex/limit_zero.json b/browser/tests/golden/behavior/find-by-regex/limit_zero.json new file mode 100644 index 000000000..39037f35a --- /dev/null +++ b/browser/tests/golden/behavior/find-by-regex/limit_zero.json @@ -0,0 +1,13 @@ +{ + "function": "browser::find-by-regex", + "case": "limit_zero", + "request": { + "html": "<html><head><title>Messy Page</title><meta charset=\"utf-8\"></head><body>\n<nav><ul><li><a href=\"/home\">Home</a></li><li><a href=\"/about\">About</a></li></ul></nav>\n<template><p>never render this</p></template>\n<div aria-hidden=\"true\">screen-reader trap</div>\n<div style=\"display:none\">hidden A</div>\n<div style=\"visibility: hidden\">hidden B</div>\n<main>\n <h1>Widget Review</h1>\n <p>Intro paragraph with <em>emphasis</em> and a <a href=\"/w/1\">link</a>.</p>\n <h2>Specs</h2>\n <table><tr><th>Name</th><th>Value</th></tr><tr><td>Weight</td><td>3kg</td></tr></table>\n <ul><li>alpha<ul><li>nested</li></ul></li><li>beta</li></ul>\n <div class=\"card\" data-id=\"1\"><h3>Card One</h3><p>first card</p></div>\n <div class=\"card\" data-id=\"2\"><h3>Card Two</h3><p>second card</p></div>\n <div class=\"card wide\" data-id=\"3\"><h3>Card Three</h3><p>third card</p></div>\n</main>\n<footer><p>© 2026 Example — <span style=\"font-size:0\">invisible</span>fine print</p></footer>\n<script>analytics()</script>\n</body></html>\n", + "pattern": "\\w+", + "limit": 0 + }, + "ok": { + "count": 28, + "items": [] + } +} diff --git a/browser/tests/golden/behavior/find-by-regex/messy_cards.json b/browser/tests/golden/behavior/find-by-regex/messy_cards.json new file mode 100644 index 000000000..7c99723e7 --- /dev/null +++ b/browser/tests/golden/behavior/find-by-regex/messy_cards.json @@ -0,0 +1,37 @@ +{ + "function": "browser::find-by-regex", + "case": "messy_cards", + "request": { + "html": "<html><head><title>Messy Page</title><meta charset=\"utf-8\"></head><body>\n<nav><ul><li><a href=\"/home\">Home</a></li><li><a href=\"/about\">About</a></li></ul></nav>\n<template><p>never render this</p></template>\n<div aria-hidden=\"true\">screen-reader trap</div>\n<div style=\"display:none\">hidden A</div>\n<div style=\"visibility: hidden\">hidden B</div>\n<main>\n <h1>Widget Review</h1>\n <p>Intro paragraph with <em>emphasis</em> and a <a href=\"/w/1\">link</a>.</p>\n <h2>Specs</h2>\n <table><tr><th>Name</th><th>Value</th></tr><tr><td>Weight</td><td>3kg</td></tr></table>\n <ul><li>alpha<ul><li>nested</li></ul></li><li>beta</li></ul>\n <div class=\"card\" data-id=\"1\"><h3>Card One</h3><p>first card</p></div>\n <div class=\"card\" data-id=\"2\"><h3>Card Two</h3><p>second card</p></div>\n <div class=\"card wide\" data-id=\"3\"><h3>Card Three</h3><p>third card</p></div>\n</main>\n<footer><p>© 2026 Example — <span style=\"font-size:0\">invisible</span>fine print</p></footer>\n<script>analytics()</script>\n</body></html>\n", + "pattern": "card$" + }, + "ok": { + "count": 3, + "items": [ + { + "tag": "p", + "text": "first card", + "html": "<p>first card</p>", + "attrs": {}, + "css": "body > main > div > p", + "xpath": "//body/main/div/p" + }, + { + "tag": "p", + "text": "second card", + "html": "<p>second card</p>", + "attrs": {}, + "css": "body > main > div:nth-of-type(2) > p", + "xpath": "//body/main/div[2]/p" + }, + { + "tag": "p", + "text": "third card", + "html": "<p>third card</p>", + "attrs": {}, + "css": "body > main > div:nth-of-type(3) > p", + "xpath": "//body/main/div[3]/p" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find-by-regex/sensitive_miss.json b/browser/tests/golden/behavior/find-by-regex/sensitive_miss.json new file mode 100644 index 000000000..28eb2bb0b --- /dev/null +++ b/browser/tests/golden/behavior/find-by-regex/sensitive_miss.json @@ -0,0 +1,13 @@ +{ + "function": "browser::find-by-regex", + "case": "sensitive_miss", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "pattern": "PRICE \\d+", + "case_sensitive": true + }, + "ok": { + "count": 0, + "items": [] + } +} diff --git a/browser/tests/golden/behavior/find-by-regex/unicode_ignorecase_extra.json b/browser/tests/golden/behavior/find-by-regex/unicode_ignorecase_extra.json new file mode 100644 index 000000000..3ca4536b0 --- /dev/null +++ b/browser/tests/golden/behavior/find-by-regex/unicode_ignorecase_extra.json @@ -0,0 +1,21 @@ +{ + "function": "browser::find-by-regex", + "case": "unicode_ignorecase_extra", + "request": { + "html": "<p>Kelvin</p>", + "pattern": "kelvin" + }, + "ok": { + "count": 1, + "items": [ + { + "tag": "p", + "text": "Kelvin", + "html": "<p>Kelvin</p>", + "attrs": {}, + "css": "body > p", + "xpath": "//body/p" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find-by-text/case_sensitive_miss.json b/browser/tests/golden/behavior/find-by-text/case_sensitive_miss.json new file mode 100644 index 000000000..7b666245e --- /dev/null +++ b/browser/tests/golden/behavior/find-by-text/case_sensitive_miss.json @@ -0,0 +1,13 @@ +{ + "function": "browser::find-by-text", + "case": "case_sensitive_miss", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "text": "apple", + "case_sensitive": true + }, + "ok": { + "count": 0, + "items": [] + } +} diff --git a/browser/tests/golden/behavior/find-by-text/clean_match_whitespace.json b/browser/tests/golden/behavior/find-by-text/clean_match_whitespace.json new file mode 100644 index 000000000..293478da1 --- /dev/null +++ b/browser/tests/golden/behavior/find-by-text/clean_match_whitespace.json @@ -0,0 +1,21 @@ +{ + "function": "browser::find-by-text", + "case": "clean_match_whitespace", + "request": { + "html": "<html><head></head><body>\n<div id=\"wrap\" class=\"outer main\">\n <span>CONDITION: <!-- separator -->Excellent</span>\n <p> <b>bold</b>after-bold</p>\n <p>lead<b>mid</b>tail</p>\n <ul><li>one</li><li>two</li><li>three</li></ul>\n <a href=\"/x?a=1&amp;b=2\" data-price=\"10\">A&amp;B</a>\n <input disabled type=\"text\">\n <script>var hidden = \"never\";</script>\n <style>.x { display: none; }</style>\n <p class=\"uni\">Ünïcode — café</p>\n <textarea> </textarea>\n <div>line one\nand two <b>x</b></div>\n <div data-x=\"1\">line oneand two <b>y</b></div>\n</div>\n</body></html>\n", + "text": "bold" + }, + "ok": { + "count": 1, + "items": [ + { + "tag": "b", + "text": "bold", + "html": "<b>bold</b>", + "attrs": {}, + "css": "#wrap > p > b", + "xpath": "//*[@id='wrap']/p/b" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find-by-text/clean_trims_trailing_space.json b/browser/tests/golden/behavior/find-by-text/clean_trims_trailing_space.json new file mode 100644 index 000000000..616ff0cad --- /dev/null +++ b/browser/tests/golden/behavior/find-by-text/clean_trims_trailing_space.json @@ -0,0 +1,21 @@ +{ + "function": "browser::find-by-text", + "case": "clean_trims_trailing_space", + "request": { + "html": "<html><head><title>Messy Page</title><meta charset=\"utf-8\"></head><body>\n<nav><ul><li><a href=\"/home\">Home</a></li><li><a href=\"/about\">About</a></li></ul></nav>\n<template><p>never render this</p></template>\n<div aria-hidden=\"true\">screen-reader trap</div>\n<div style=\"display:none\">hidden A</div>\n<div style=\"visibility: hidden\">hidden B</div>\n<main>\n <h1>Widget Review</h1>\n <p>Intro paragraph with <em>emphasis</em> and a <a href=\"/w/1\">link</a>.</p>\n <h2>Specs</h2>\n <table><tr><th>Name</th><th>Value</th></tr><tr><td>Weight</td><td>3kg</td></tr></table>\n <ul><li>alpha<ul><li>nested</li></ul></li><li>beta</li></ul>\n <div class=\"card\" data-id=\"1\"><h3>Card One</h3><p>first card</p></div>\n <div class=\"card\" data-id=\"2\"><h3>Card Two</h3><p>second card</p></div>\n <div class=\"card wide\" data-id=\"3\"><h3>Card Three</h3><p>third card</p></div>\n</main>\n<footer><p>© 2026 Example — <span style=\"font-size:0\">invisible</span>fine print</p></footer>\n<script>analytics()</script>\n</body></html>\n", + "text": "intro paragraph with" + }, + "ok": { + "count": 1, + "items": [ + { + "tag": "p", + "text": "Intro paragraph with\nemphasis\nand a\nlink\n.", + "html": "<p>Intro paragraph with <em>emphasis</em> and a <a href=\"/w/1\">link</a>.</p>", + "attrs": {}, + "css": "body > main > p", + "xpath": "//body/main/p" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find-by-text/exact_default.json b/browser/tests/golden/behavior/find-by-text/exact_default.json new file mode 100644 index 000000000..23019b892 --- /dev/null +++ b/browser/tests/golden/behavior/find-by-text/exact_default.json @@ -0,0 +1,23 @@ +{ + "function": "browser::find-by-text", + "case": "exact_default", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "text": "Apple" + }, + "ok": { + "count": 1, + "items": [ + { + "tag": "a", + "text": "Apple", + "html": "<a href=\"/a\">Apple</a>", + "attrs": { + "href": "/a" + }, + "css": "body > ul > li > a", + "xpath": "//body/ul/li/a" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find-by-text/first_flag.json b/browser/tests/golden/behavior/find-by-text/first_flag.json new file mode 100644 index 000000000..b5be96f68 --- /dev/null +++ b/browser/tests/golden/behavior/find-by-text/first_flag.json @@ -0,0 +1,24 @@ +{ + "function": "browser::find-by-text", + "case": "first_flag", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "text": "Apple", + "first": true + }, + "ok": { + "count": 1, + "items": [ + { + "tag": "a", + "text": "Apple", + "html": "<a href=\"/a\">Apple</a>", + "attrs": { + "href": "/a" + }, + "css": "body > ul > li > a", + "xpath": "//body/ul/li/a" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find-by-text/no_clean_exact_ws.json b/browser/tests/golden/behavior/find-by-text/no_clean_exact_ws.json new file mode 100644 index 000000000..4cdb7c359 --- /dev/null +++ b/browser/tests/golden/behavior/find-by-text/no_clean_exact_ws.json @@ -0,0 +1,22 @@ +{ + "function": "browser::find-by-text", + "case": "no_clean_exact_ws", + "request": { + "html": "<html><head></head><body>\n<div id=\"wrap\" class=\"outer main\">\n <span>CONDITION: <!-- separator -->Excellent</span>\n <p> <b>bold</b>after-bold</p>\n <p>lead<b>mid</b>tail</p>\n <ul><li>one</li><li>two</li><li>three</li></ul>\n <a href=\"/x?a=1&amp;b=2\" data-price=\"10\">A&amp;B</a>\n <input disabled type=\"text\">\n <script>var hidden = \"never\";</script>\n <style>.x { display: none; }</style>\n <p class=\"uni\">Ünïcode — café</p>\n <textarea> </textarea>\n <div>line one\nand two <b>x</b></div>\n <div data-x=\"1\">line oneand two <b>y</b></div>\n</div>\n</body></html>\n", + "text": "bold", + "clean_match": false + }, + "ok": { + "count": 1, + "items": [ + { + "tag": "b", + "text": "bold", + "html": "<b>bold</b>", + "attrs": {}, + "css": "#wrap > p > b", + "xpath": "//*[@id='wrap']/p/b" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find-by-text/no_clean_keeps_trailing_space.json b/browser/tests/golden/behavior/find-by-text/no_clean_keeps_trailing_space.json new file mode 100644 index 000000000..ed2fa8d03 --- /dev/null +++ b/browser/tests/golden/behavior/find-by-text/no_clean_keeps_trailing_space.json @@ -0,0 +1,13 @@ +{ + "function": "browser::find-by-text", + "case": "no_clean_keeps_trailing_space", + "request": { + "html": "<html><head><title>Messy Page</title><meta charset=\"utf-8\"></head><body>\n<nav><ul><li><a href=\"/home\">Home</a></li><li><a href=\"/about\">About</a></li></ul></nav>\n<template><p>never render this</p></template>\n<div aria-hidden=\"true\">screen-reader trap</div>\n<div style=\"display:none\">hidden A</div>\n<div style=\"visibility: hidden\">hidden B</div>\n<main>\n <h1>Widget Review</h1>\n <p>Intro paragraph with <em>emphasis</em> and a <a href=\"/w/1\">link</a>.</p>\n <h2>Specs</h2>\n <table><tr><th>Name</th><th>Value</th></tr><tr><td>Weight</td><td>3kg</td></tr></table>\n <ul><li>alpha<ul><li>nested</li></ul></li><li>beta</li></ul>\n <div class=\"card\" data-id=\"1\"><h3>Card One</h3><p>first card</p></div>\n <div class=\"card\" data-id=\"2\"><h3>Card Two</h3><p>second card</p></div>\n <div class=\"card wide\" data-id=\"3\"><h3>Card Three</h3><p>third card</p></div>\n</main>\n<footer><p>© 2026 Example — <span style=\"font-size:0\">invisible</span>fine print</p></footer>\n<script>analytics()</script>\n</body></html>\n", + "text": "intro paragraph with", + "clean_match": false + }, + "ok": { + "count": 0, + "items": [] + } +} diff --git a/browser/tests/golden/behavior/find-by-text/none.json b/browser/tests/golden/behavior/find-by-text/none.json new file mode 100644 index 000000000..450c06579 --- /dev/null +++ b/browser/tests/golden/behavior/find-by-text/none.json @@ -0,0 +1,12 @@ +{ + "function": "browser::find-by-text", + "case": "none", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "text": "Zebra" + }, + "ok": { + "count": 0, + "items": [] + } +} diff --git a/browser/tests/golden/behavior/find-by-text/partial_case.json b/browser/tests/golden/behavior/find-by-text/partial_case.json new file mode 100644 index 000000000..cfb76e0d2 --- /dev/null +++ b/browser/tests/golden/behavior/find-by-text/partial_case.json @@ -0,0 +1,24 @@ +{ + "function": "browser::find-by-text", + "case": "partial_case", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "text": "aPP", + "partial": true + }, + "ok": { + "count": 1, + "items": [ + { + "tag": "a", + "text": "Apple", + "html": "<a href=\"/a\">Apple</a>", + "attrs": { + "href": "/a" + }, + "css": "body > ul > li > a", + "xpath": "//body/ul/li/a" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find-similar/anchor_missing.json b/browser/tests/golden/behavior/find-similar/anchor_missing.json new file mode 100644 index 000000000..7b8ac26f8 --- /dev/null +++ b/browser/tests/golden/behavior/find-similar/anchor_missing.json @@ -0,0 +1,12 @@ +{ + "function": "browser::find-similar", + "case": "anchor_missing", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "anchor": ".nope" + }, + "ok": { + "count": 0, + "items": [] + } +} diff --git a/browser/tests/golden/behavior/find-similar/cards_attr_scoring.json b/browser/tests/golden/behavior/find-similar/cards_attr_scoring.json new file mode 100644 index 000000000..a644d762a --- /dev/null +++ b/browser/tests/golden/behavior/find-similar/cards_attr_scoring.json @@ -0,0 +1,25 @@ +{ + "function": "browser::find-similar", + "case": "cards_attr_scoring", + "request": { + "html": "<html><head><title>Messy Page</title><meta charset=\"utf-8\"></head><body>\n<nav><ul><li><a href=\"/home\">Home</a></li><li><a href=\"/about\">About</a></li></ul></nav>\n<template><p>never render this</p></template>\n<div aria-hidden=\"true\">screen-reader trap</div>\n<div style=\"display:none\">hidden A</div>\n<div style=\"visibility: hidden\">hidden B</div>\n<main>\n <h1>Widget Review</h1>\n <p>Intro paragraph with <em>emphasis</em> and a <a href=\"/w/1\">link</a>.</p>\n <h2>Specs</h2>\n <table><tr><th>Name</th><th>Value</th></tr><tr><td>Weight</td><td>3kg</td></tr></table>\n <ul><li>alpha<ul><li>nested</li></ul></li><li>beta</li></ul>\n <div class=\"card\" data-id=\"1\"><h3>Card One</h3><p>first card</p></div>\n <div class=\"card\" data-id=\"2\"><h3>Card Two</h3><p>second card</p></div>\n <div class=\"card wide\" data-id=\"3\"><h3>Card Three</h3><p>third card</p></div>\n</main>\n<footer><p>© 2026 Example — <span style=\"font-size:0\">invisible</span>fine print</p></footer>\n<script>analytics()</script>\n</body></html>\n", + "anchor": "div.card[data-id='1']" + }, + "ok": { + "count": 3, + "items": [ + { + "text": "Card One\nfirst card", + "html": "<div class=\"card\" data-id=\"1\"><h3>Card One</h3><p>first card</p></div>" + }, + { + "text": "Card Two\nsecond card", + "html": "<div class=\"card\" data-id=\"2\"><h3>Card Two</h3><p>second card</p></div>" + }, + { + "text": "Card Three\nthird card", + "html": "<div class=\"card wide\" data-id=\"3\"><h3>Card Three</h3><p>third card</p></div>" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find-similar/cards_high_threshold.json b/browser/tests/golden/behavior/find-similar/cards_high_threshold.json new file mode 100644 index 000000000..9808f28df --- /dev/null +++ b/browser/tests/golden/behavior/find-similar/cards_high_threshold.json @@ -0,0 +1,18 @@ +{ + "function": "browser::find-similar", + "case": "cards_high_threshold", + "request": { + "html": "<html><head><title>Messy Page</title><meta charset=\"utf-8\"></head><body>\n<nav><ul><li><a href=\"/home\">Home</a></li><li><a href=\"/about\">About</a></li></ul></nav>\n<template><p>never render this</p></template>\n<div aria-hidden=\"true\">screen-reader trap</div>\n<div style=\"display:none\">hidden A</div>\n<div style=\"visibility: hidden\">hidden B</div>\n<main>\n <h1>Widget Review</h1>\n <p>Intro paragraph with <em>emphasis</em> and a <a href=\"/w/1\">link</a>.</p>\n <h2>Specs</h2>\n <table><tr><th>Name</th><th>Value</th></tr><tr><td>Weight</td><td>3kg</td></tr></table>\n <ul><li>alpha<ul><li>nested</li></ul></li><li>beta</li></ul>\n <div class=\"card\" data-id=\"1\"><h3>Card One</h3><p>first card</p></div>\n <div class=\"card\" data-id=\"2\"><h3>Card Two</h3><p>second card</p></div>\n <div class=\"card wide\" data-id=\"3\"><h3>Card Three</h3><p>third card</p></div>\n</main>\n<footer><p>© 2026 Example — <span style=\"font-size:0\">invisible</span>fine print</p></footer>\n<script>analytics()</script>\n</body></html>\n", + "anchor": "div.card[data-id='1']", + "similarity_threshold": 0.9 + }, + "ok": { + "count": 1, + "items": [ + { + "text": "Card One\nfirst card", + "html": "<div class=\"card\" data-id=\"1\"><h3>Card One</h3><p>first card</p></div>" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find-similar/list_items.json b/browser/tests/golden/behavior/find-similar/list_items.json new file mode 100644 index 000000000..676f30d30 --- /dev/null +++ b/browser/tests/golden/behavior/find-similar/list_items.json @@ -0,0 +1,21 @@ +{ + "function": "browser::find-similar", + "case": "list_items", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "anchor": "li" + }, + "ok": { + "count": 2, + "items": [ + { + "text": "Apple", + "html": "<li><a href=\"/a\">Apple</a></li>" + }, + { + "text": "Banana", + "html": "<li><a href=\"/b\">Banana</a></li>" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find-similar/match_text.json b/browser/tests/golden/behavior/find-similar/match_text.json new file mode 100644 index 000000000..3c001eea6 --- /dev/null +++ b/browser/tests/golden/behavior/find-similar/match_text.json @@ -0,0 +1,22 @@ +{ + "function": "browser::find-similar", + "case": "match_text", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "anchor": "li", + "match_text": true + }, + "ok": { + "count": 2, + "items": [ + { + "text": "Apple", + "html": "<li><a href=\"/a\">Apple</a></li>" + }, + { + "text": "Banana", + "html": "<li><a href=\"/b\">Banana</a></li>" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find-similar/match_text_multiline_leading.json b/browser/tests/golden/behavior/find-similar/match_text_multiline_leading.json new file mode 100644 index 000000000..06b5e2f26 --- /dev/null +++ b/browser/tests/golden/behavior/find-similar/match_text_multiline_leading.json @@ -0,0 +1,23 @@ +{ + "function": "browser::find-similar", + "case": "match_text_multiline_leading", + "request": { + "html": "<html><head></head><body>\n<div id=\"wrap\" class=\"outer main\">\n <span>CONDITION: <!-- separator -->Excellent</span>\n <p> <b>bold</b>after-bold</p>\n <p>lead<b>mid</b>tail</p>\n <ul><li>one</li><li>two</li><li>three</li></ul>\n <a href=\"/x?a=1&amp;b=2\" data-price=\"10\">A&amp;B</a>\n <input disabled type=\"text\">\n <script>var hidden = \"never\";</script>\n <style>.x { display: none; }</style>\n <p class=\"uni\">Ünïcode — café</p>\n <textarea> </textarea>\n <div>line one\nand two <b>x</b></div>\n <div data-x=\"1\">line oneand two <b>y</b></div>\n</div>\n</body></html>\n", + "anchor": "#wrap > div", + "match_text": true, + "similarity_threshold": 0.99 + }, + "ok": { + "count": 2, + "items": [ + { + "text": "line one\nand two \nx", + "html": "<div>line one\nand two <b>x</b></div>" + }, + { + "text": "line oneand two \ny", + "html": "<div data-x=\"1\">line oneand two <b>y</b></div>" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find-similar/subselector_scope_cannot_escape.json b/browser/tests/golden/behavior/find-similar/subselector_scope_cannot_escape.json new file mode 100644 index 000000000..3e50125f2 --- /dev/null +++ b/browser/tests/golden/behavior/find-similar/subselector_scope_cannot_escape.json @@ -0,0 +1,31 @@ +{ + "function": "browser::find-similar", + "case": "subselector_scope_cannot_escape", + "request": { + "html": "<main><section><a>inside</a></section></main><a>outside</a>", + "anchor": "section", + "selectors": [ + { + "name": "escaped", + "css": "body a", + "all": true + }, + { + "name": "inside", + "css": "a", + "all": true + } + ] + }, + "ok": { + "count": 1, + "items": [ + { + "escaped": [], + "inside": [ + "inside" + ] + } + ] + } +} diff --git a/browser/tests/golden/behavior/find-similar/subselectors.json b/browser/tests/golden/behavior/find-similar/subselectors.json new file mode 100644 index 000000000..72f6d91b1 --- /dev/null +++ b/browser/tests/golden/behavior/find-similar/subselectors.json @@ -0,0 +1,26 @@ +{ + "function": "browser::find-similar", + "case": "subselectors", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "anchor": "li", + "selectors": [ + { + "name": "href", + "css": "a", + "attr": "href" + } + ] + }, + "ok": { + "count": 2, + "items": [ + { + "href": "/a" + }, + { + "href": "/b" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find/attrs_bool_coercion.json b/browser/tests/golden/behavior/find/attrs_bool_coercion.json new file mode 100644 index 000000000..5aa76a773 --- /dev/null +++ b/browser/tests/golden/behavior/find/attrs_bool_coercion.json @@ -0,0 +1,15 @@ +{ + "function": "browser::find", + "case": "attrs_bool_coercion", + "request": { + "html": "<html><head></head><body>\n<div id=\"wrap\" class=\"outer main\">\n <span>CONDITION: <!-- separator -->Excellent</span>\n <p> <b>bold</b>after-bold</p>\n <p>lead<b>mid</b>tail</p>\n <ul><li>one</li><li>two</li><li>three</li></ul>\n <a href=\"/x?a=1&amp;b=2\" data-price=\"10\">A&amp;B</a>\n <input disabled type=\"text\">\n <script>var hidden = \"never\";</script>\n <style>.x { display: none; }</style>\n <p class=\"uni\">Ünïcode — café</p>\n <textarea> </textarea>\n <div>line one\nand two <b>x</b></div>\n <div data-x=\"1\">line oneand two <b>y</b></div>\n</div>\n</body></html>\n", + "tag": "input", + "attrs": { + "disabled": "" + } + }, + "ok": { + "count": 0, + "items": [] + } +} diff --git a/browser/tests/golden/behavior/find/attrs_exact_whole_value.json b/browser/tests/golden/behavior/find/attrs_exact_whole_value.json new file mode 100644 index 000000000..bcdeb9a92 --- /dev/null +++ b/browser/tests/golden/behavior/find/attrs_exact_whole_value.json @@ -0,0 +1,37 @@ +{ + "function": "browser::find", + "case": "attrs_exact_whole_value", + "request": { + "html": "<html><head><title>Messy Page</title><meta charset=\"utf-8\"></head><body>\n<nav><ul><li><a href=\"/home\">Home</a></li><li><a href=\"/about\">About</a></li></ul></nav>\n<template><p>never render this</p></template>\n<div aria-hidden=\"true\">screen-reader trap</div>\n<div style=\"display:none\">hidden A</div>\n<div style=\"visibility: hidden\">hidden B</div>\n<main>\n <h1>Widget Review</h1>\n <p>Intro paragraph with <em>emphasis</em> and a <a href=\"/w/1\">link</a>.</p>\n <h2>Specs</h2>\n <table><tr><th>Name</th><th>Value</th></tr><tr><td>Weight</td><td>3kg</td></tr></table>\n <ul><li>alpha<ul><li>nested</li></ul></li><li>beta</li></ul>\n <div class=\"card\" data-id=\"1\"><h3>Card One</h3><p>first card</p></div>\n <div class=\"card\" data-id=\"2\"><h3>Card Two</h3><p>second card</p></div>\n <div class=\"card wide\" data-id=\"3\"><h3>Card Three</h3><p>third card</p></div>\n</main>\n<footer><p>© 2026 Example — <span style=\"font-size:0\">invisible</span>fine print</p></footer>\n<script>analytics()</script>\n</body></html>\n", + "attrs": { + "class": "card" + } + }, + "ok": { + "count": 2, + "items": [ + { + "tag": "div", + "text": "Card One\nfirst card", + "html": "<div class=\"card\" data-id=\"1\"><h3>Card One</h3><p>first card</p></div>", + "attrs": { + "class": "card", + "data-id": "1" + }, + "css": "body > main > div", + "xpath": "//body/main/div" + }, + { + "tag": "div", + "text": "Card Two\nsecond card", + "html": "<div class=\"card\" data-id=\"2\"><h3>Card Two</h3><p>second card</p></div>", + "attrs": { + "class": "card", + "data-id": "2" + }, + "css": "body > main > div:nth-of-type(2)", + "xpath": "//body/main/div[2]" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find/attrs_operator_contains.json b/browser/tests/golden/behavior/find/attrs_operator_contains.json new file mode 100644 index 000000000..c4db3889e --- /dev/null +++ b/browser/tests/golden/behavior/find/attrs_operator_contains.json @@ -0,0 +1,25 @@ +{ + "function": "browser::find", + "case": "attrs_operator_contains", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "attrs": { + "href*": "/a" + } + }, + "ok": { + "count": 1, + "items": [ + { + "tag": "a", + "text": "Apple", + "html": "<a href=\"/a\">Apple</a>", + "attrs": { + "href": "/a" + }, + "css": "body > ul > li > a", + "xpath": "//body/ul/li/a" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find/attrs_operator_prefix.json b/browser/tests/golden/behavior/find/attrs_operator_prefix.json new file mode 100644 index 000000000..45bf71957 --- /dev/null +++ b/browser/tests/golden/behavior/find/attrs_operator_prefix.json @@ -0,0 +1,35 @@ +{ + "function": "browser::find", + "case": "attrs_operator_prefix", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "attrs": { + "href^": "/" + } + }, + "ok": { + "count": 2, + "items": [ + { + "tag": "a", + "text": "Apple", + "html": "<a href=\"/a\">Apple</a>", + "attrs": { + "href": "/a" + }, + "css": "body > ul > li > a", + "xpath": "//body/ul/li/a" + }, + { + "tag": "a", + "text": "Banana", + "html": "<a href=\"/b\">Banana</a>", + "attrs": { + "href": "/b" + }, + "css": "body > ul > li:nth-of-type(2) > a", + "xpath": "//body/ul/li[2]/a" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find/by_tag.json b/browser/tests/golden/behavior/find/by_tag.json new file mode 100644 index 000000000..fbaaf9096 --- /dev/null +++ b/browser/tests/golden/behavior/find/by_tag.json @@ -0,0 +1,33 @@ +{ + "function": "browser::find", + "case": "by_tag", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "tag": "a" + }, + "ok": { + "count": 2, + "items": [ + { + "tag": "a", + "text": "Apple", + "html": "<a href=\"/a\">Apple</a>", + "attrs": { + "href": "/a" + }, + "css": "body > ul > li > a", + "xpath": "//body/ul/li/a" + }, + { + "tag": "a", + "text": "Banana", + "html": "<a href=\"/b\">Banana</a>", + "attrs": { + "href": "/b" + }, + "css": "body > ul > li:nth-of-type(2) > a", + "xpath": "//body/ul/li[2]/a" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find/by_tag_input_attrs_map.json b/browser/tests/golden/behavior/find/by_tag_input_attrs_map.json new file mode 100644 index 000000000..9df943209 --- /dev/null +++ b/browser/tests/golden/behavior/find/by_tag_input_attrs_map.json @@ -0,0 +1,24 @@ +{ + "function": "browser::find", + "case": "by_tag_input_attrs_map", + "request": { + "html": "<html><head></head><body>\n<div id=\"wrap\" class=\"outer main\">\n <span>CONDITION: <!-- separator -->Excellent</span>\n <p> <b>bold</b>after-bold</p>\n <p>lead<b>mid</b>tail</p>\n <ul><li>one</li><li>two</li><li>three</li></ul>\n <a href=\"/x?a=1&amp;b=2\" data-price=\"10\">A&amp;B</a>\n <input disabled type=\"text\">\n <script>var hidden = \"never\";</script>\n <style>.x { display: none; }</style>\n <p class=\"uni\">Ünïcode — café</p>\n <textarea> </textarea>\n <div>line one\nand two <b>x</b></div>\n <div data-x=\"1\">line oneand two <b>y</b></div>\n</div>\n</body></html>\n", + "tag": "input" + }, + "ok": { + "count": 1, + "items": [ + { + "tag": "input", + "text": "", + "html": "<input disabled type=\"text\">", + "attrs": { + "disabled": "disabled", + "type": "text" + }, + "css": "#wrap > input", + "xpath": "//*[@id='wrap']/input" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find/by_tag_list.json b/browser/tests/golden/behavior/find/by_tag_list.json new file mode 100644 index 000000000..2dc7207d6 --- /dev/null +++ b/browser/tests/golden/behavior/find/by_tag_list.json @@ -0,0 +1,34 @@ +{ + "function": "browser::find", + "case": "by_tag_list", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "tag": [ + "h1", + "p" + ] + }, + "ok": { + "count": 2, + "items": [ + { + "tag": "h1", + "text": "Hello", + "html": "<h1 class=\"t\">Hello</h1>", + "attrs": { + "class": "t" + }, + "css": "body > h1", + "xpath": "//body/h1" + }, + { + "tag": "p", + "text": "price 42 usd then 99", + "html": "<p>price 42 usd then 99</p>", + "attrs": {}, + "css": "body > p", + "xpath": "//body/p" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find/duplicate_and_boolean_attributes.json b/browser/tests/golden/behavior/find/duplicate_and_boolean_attributes.json new file mode 100644 index 000000000..7e7859b2d --- /dev/null +++ b/browser/tests/golden/behavior/find/duplicate_and_boolean_attributes.json @@ -0,0 +1,26 @@ +{ + "function": "browser::find", + "case": "duplicate_and_boolean_attributes", + "request": { + "html": "<input z=1 disabled a='' z=2 checked=checked>", + "tag": "input" + }, + "ok": { + "count": 1, + "items": [ + { + "tag": "input", + "text": "", + "html": "<input z=\"1\" disabled a=\"\" checked>", + "attrs": { + "z": "1", + "disabled": "disabled", + "a": "", + "checked": "checked" + }, + "css": "body > input", + "xpath": "//body/input" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find/empty_text_regex_error.json b/browser/tests/golden/behavior/find/empty_text_regex_error.json new file mode 100644 index 000000000..12ef42970 --- /dev/null +++ b/browser/tests/golden/behavior/find/empty_text_regex_error.json @@ -0,0 +1,9 @@ +{ + "function": "browser::find", + "case": "empty_text_regex_error", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "text_regex": "" + }, + "err": "provide at least one of `tag`, `attrs`, `text_regex`" +} diff --git a/browser/tests/golden/behavior/find/implied_document_nodes.json b/browser/tests/golden/behavior/find/implied_document_nodes.json new file mode 100644 index 000000000..d29c6ca13 --- /dev/null +++ b/browser/tests/golden/behavior/find/implied_document_nodes.json @@ -0,0 +1,41 @@ +{ + "function": "browser::find", + "case": "implied_document_nodes", + "request": { + "html": "<title>T</title><p>P", + "tag": [ + "html", + "head", + "body" + ] + }, + "ok": { + "count": 3, + "items": [ + { + "tag": "html", + "text": "T\nP", + "html": "<html><head><title>T</title></head><body><p>P</p></body></html>", + "attrs": {}, + "css": "", + "xpath": "//" + }, + { + "tag": "head", + "text": "T", + "html": "<head><title>T</title></head>", + "attrs": {}, + "css": "head", + "xpath": "//head" + }, + { + "tag": "body", + "text": "P", + "html": "<body><p>P</p></body>", + "attrs": {}, + "css": "body", + "xpath": "//body" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find/limit_clamps.json b/browser/tests/golden/behavior/find/limit_clamps.json new file mode 100644 index 000000000..44864aa57 --- /dev/null +++ b/browser/tests/golden/behavior/find/limit_clamps.json @@ -0,0 +1,30 @@ +{ + "function": "browser::find", + "case": "limit_clamps", + "request": { + "html": "<html><head><title>Messy Page</title><meta charset=\"utf-8\"></head><body>\n<nav><ul><li><a href=\"/home\">Home</a></li><li><a href=\"/about\">About</a></li></ul></nav>\n<template><p>never render this</p></template>\n<div aria-hidden=\"true\">screen-reader trap</div>\n<div style=\"display:none\">hidden A</div>\n<div style=\"visibility: hidden\">hidden B</div>\n<main>\n <h1>Widget Review</h1>\n <p>Intro paragraph with <em>emphasis</em> and a <a href=\"/w/1\">link</a>.</p>\n <h2>Specs</h2>\n <table><tr><th>Name</th><th>Value</th></tr><tr><td>Weight</td><td>3kg</td></tr></table>\n <ul><li>alpha<ul><li>nested</li></ul></li><li>beta</li></ul>\n <div class=\"card\" data-id=\"1\"><h3>Card One</h3><p>first card</p></div>\n <div class=\"card\" data-id=\"2\"><h3>Card Two</h3><p>second card</p></div>\n <div class=\"card wide\" data-id=\"3\"><h3>Card Three</h3><p>third card</p></div>\n</main>\n<footer><p>© 2026 Example — <span style=\"font-size:0\">invisible</span>fine print</p></footer>\n<script>analytics()</script>\n</body></html>\n", + "tag": "li", + "limit": 2 + }, + "ok": { + "count": 5, + "items": [ + { + "tag": "li", + "text": "Home", + "html": "<li><a href=\"/home\">Home</a></li>", + "attrs": {}, + "css": "body > nav > ul > li", + "xpath": "//body/nav/ul/li" + }, + { + "tag": "li", + "text": "About", + "html": "<li><a href=\"/about\">About</a></li>", + "attrs": {}, + "css": "body > nav > ul > li:nth-of-type(2)", + "xpath": "//body/nav/ul/li[2]" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find/negative_limit_empty.json b/browser/tests/golden/behavior/find/negative_limit_empty.json new file mode 100644 index 000000000..9611b8a11 --- /dev/null +++ b/browser/tests/golden/behavior/find/negative_limit_empty.json @@ -0,0 +1,13 @@ +{ + "function": "browser::find", + "case": "negative_limit_empty", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "tag": "a", + "limit": -1 + }, + "ok": { + "count": 2, + "items": [] + } +} diff --git a/browser/tests/golden/behavior/find/no_filters_error.json b/browser/tests/golden/behavior/find/no_filters_error.json new file mode 100644 index 000000000..6861cc873 --- /dev/null +++ b/browser/tests/golden/behavior/find/no_filters_error.json @@ -0,0 +1,8 @@ +{ + "function": "browser::find", + "case": "no_filters_error", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n" + }, + "err": "provide at least one of `tag`, `attrs`, `text_regex`" +} diff --git a/browser/tests/golden/behavior/find/star_tag_falls_through.json b/browser/tests/golden/behavior/find/star_tag_falls_through.json new file mode 100644 index 000000000..adf6203d7 --- /dev/null +++ b/browser/tests/golden/behavior/find/star_tag_falls_through.json @@ -0,0 +1,91 @@ +{ + "function": "browser::find", + "case": "star_tag_falls_through", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "tag": "*" + }, + "ok": { + "count": 9, + "items": [ + { + "tag": "head", + "text": "", + "html": "<head></head>", + "attrs": {}, + "css": "head", + "xpath": "//head" + }, + { + "tag": "body", + "text": "Hello\nApple\nBanana\nprice 42 usd then 99", + "html": "<body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body>", + "attrs": {}, + "css": "body", + "xpath": "//body" + }, + { + "tag": "h1", + "text": "Hello", + "html": "<h1 class=\"t\">Hello</h1>", + "attrs": { + "class": "t" + }, + "css": "body > h1", + "xpath": "//body/h1" + }, + { + "tag": "ul", + "text": "Apple\nBanana", + "html": "<ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul>", + "attrs": {}, + "css": "body > ul", + "xpath": "//body/ul" + }, + { + "tag": "li", + "text": "Apple", + "html": "<li><a href=\"/a\">Apple</a></li>", + "attrs": {}, + "css": "body > ul > li", + "xpath": "//body/ul/li" + }, + { + "tag": "a", + "text": "Apple", + "html": "<a href=\"/a\">Apple</a>", + "attrs": { + "href": "/a" + }, + "css": "body > ul > li > a", + "xpath": "//body/ul/li/a" + }, + { + "tag": "li", + "text": "Banana", + "html": "<li><a href=\"/b\">Banana</a></li>", + "attrs": {}, + "css": "body > ul > li:nth-of-type(2)", + "xpath": "//body/ul/li[2]" + }, + { + "tag": "a", + "text": "Banana", + "html": "<a href=\"/b\">Banana</a>", + "attrs": { + "href": "/b" + }, + "css": "body > ul > li:nth-of-type(2) > a", + "xpath": "//body/ul/li[2]/a" + }, + { + "tag": "p", + "text": "price 42 usd then 99", + "html": "<p>price 42 usd then 99</p>", + "attrs": {}, + "css": "body > p", + "xpath": "//body/p" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find/tag_and_text_regex.json b/browser/tests/golden/behavior/find/tag_and_text_regex.json new file mode 100644 index 000000000..87917899a --- /dev/null +++ b/browser/tests/golden/behavior/find/tag_and_text_regex.json @@ -0,0 +1,25 @@ +{ + "function": "browser::find", + "case": "tag_and_text_regex", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "tag": "a", + "text_regex": "Ap", + "first": true + }, + "ok": { + "count": 1, + "items": [ + { + "tag": "a", + "text": "Apple", + "html": "<a href=\"/a\">Apple</a>", + "attrs": { + "href": "/a" + }, + "css": "body > ul > li > a", + "xpath": "//body/ul/li/a" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find/tag_html_root.json b/browser/tests/golden/behavior/find/tag_html_root.json new file mode 100644 index 000000000..9e227a9fe --- /dev/null +++ b/browser/tests/golden/behavior/find/tag_html_root.json @@ -0,0 +1,21 @@ +{ + "function": "browser::find", + "case": "tag_html_root", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "tag": "html" + }, + "ok": { + "count": 1, + "items": [ + { + "tag": "html", + "text": "Hello\nApple\nBanana\nprice 42 usd then 99", + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>", + "attrs": {}, + "css": "", + "xpath": "//" + } + ] + } +} diff --git a/browser/tests/golden/behavior/find/text_regex_only_all_elements.json b/browser/tests/golden/behavior/find/text_regex_only_all_elements.json new file mode 100644 index 000000000..84a33f2b1 --- /dev/null +++ b/browser/tests/golden/behavior/find/text_regex_only_all_elements.json @@ -0,0 +1,21 @@ +{ + "function": "browser::find", + "case": "text_regex_only_all_elements", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "text_regex": "price" + }, + "ok": { + "count": 1, + "items": [ + { + "tag": "p", + "text": "price 42 usd then 99", + "html": "<p>price 42 usd then 99</p>", + "attrs": {}, + "css": "body > p", + "xpath": "//body/p" + } + ] + } +} diff --git a/browser/tests/golden/behavior/regex/across_fragments_edge.json b/browser/tests/golden/behavior/regex/across_fragments_edge.json new file mode 100644 index 000000000..b0493de58 --- /dev/null +++ b/browser/tests/golden/behavior/regex/across_fragments_edge.json @@ -0,0 +1,13 @@ +{ + "function": "browser::regex", + "case": "across_fragments_edge", + "request": { + "html": "<html><head></head><body>\n<div id=\"wrap\" class=\"outer main\">\n <span>CONDITION: <!-- separator -->Excellent</span>\n <p> <b>bold</b>after-bold</p>\n <p>lead<b>mid</b>tail</p>\n <ul><li>one</li><li>two</li><li>three</li></ul>\n <a href=\"/x?a=1&amp;b=2\" data-price=\"10\">A&amp;B</a>\n <input disabled type=\"text\">\n <script>var hidden = \"never\";</script>\n <style>.x { display: none; }</style>\n <p class=\"uni\">Ünïcode — café</p>\n <textarea> </textarea>\n <div>line one\nand two <b>x</b></div>\n <div data-x=\"1\">line oneand two <b>y</b></div>\n</div>\n</body></html>\n", + "pattern": "CONDITION: \\w+" + }, + "ok": { + "result": [ + "CONDITION: Excellent" + ] + } +} diff --git a/browser/tests/golden/behavior/regex/atomic_and_possessive.json b/browser/tests/golden/behavior/regex/atomic_and_possessive.json new file mode 100644 index 000000000..6cd63d2dc --- /dev/null +++ b/browser/tests/golden/behavior/regex/atomic_and_possessive.json @@ -0,0 +1,13 @@ +{ + "function": "browser::regex", + "case": "atomic_and_possessive", + "request": { + "html": "<p>aaab aaaa</p>", + "pattern": "(?>a+)b|a++a" + }, + "ok": { + "result": [ + "aaab" + ] + } +} diff --git a/browser/tests/golden/behavior/regex/entities_edge.json b/browser/tests/golden/behavior/regex/entities_edge.json new file mode 100644 index 000000000..37917015f --- /dev/null +++ b/browser/tests/golden/behavior/regex/entities_edge.json @@ -0,0 +1,13 @@ +{ + "function": "browser::regex", + "case": "entities_edge", + "request": { + "html": "<html><head></head><body>\n<div id=\"wrap\" class=\"outer main\">\n <span>CONDITION: <!-- separator -->Excellent</span>\n <p> <b>bold</b>after-bold</p>\n <p>lead<b>mid</b>tail</p>\n <ul><li>one</li><li>two</li><li>three</li></ul>\n <a href=\"/x?a=1&amp;b=2\" data-price=\"10\">A&amp;B</a>\n <input disabled type=\"text\">\n <script>var hidden = \"never\";</script>\n <style>.x { display: none; }</style>\n <p class=\"uni\">Ünïcode — café</p>\n <textarea> </textarea>\n <div>line one\nand two <b>x</b></div>\n <div data-x=\"1\">line oneand two <b>y</b></div>\n</div>\n</body></html>\n", + "pattern": "A&B" + }, + "ok": { + "result": [ + "A&B" + ] + } +} diff --git a/browser/tests/golden/behavior/regex/first_group.json b/browser/tests/golden/behavior/regex/first_group.json new file mode 100644 index 000000000..1f7e8bc1c --- /dev/null +++ b/browser/tests/golden/behavior/regex/first_group.json @@ -0,0 +1,12 @@ +{ + "function": "browser::regex", + "case": "first_group", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "pattern": "price (\\d+)", + "first": true + }, + "ok": { + "result": "42" + } +} diff --git a/browser/tests/golden/behavior/regex/invalid_global_flag_position.json b/browser/tests/golden/behavior/regex/invalid_global_flag_position.json new file mode 100644 index 000000000..c831773c9 --- /dev/null +++ b/browser/tests/golden/behavior/regex/invalid_global_flag_position.json @@ -0,0 +1,9 @@ +{ + "function": "browser::regex", + "case": "invalid_global_flag_position", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "pattern": "a(?i)b" + }, + "err": "global flags not at the start of the expression at position 1" +} diff --git a/browser/tests/golden/behavior/regex/invalid_group_reference.json b/browser/tests/golden/behavior/regex/invalid_group_reference.json new file mode 100644 index 000000000..43f509ef1 --- /dev/null +++ b/browser/tests/golden/behavior/regex/invalid_group_reference.json @@ -0,0 +1,9 @@ +{ + "function": "browser::regex", + "case": "invalid_group_reference", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "pattern": "\\1" + }, + "err": "invalid group reference 1 at position 1" +} diff --git a/browser/tests/golden/behavior/regex/invalid_range.json b/browser/tests/golden/behavior/regex/invalid_range.json new file mode 100644 index 000000000..a698b4b99 --- /dev/null +++ b/browser/tests/golden/behavior/regex/invalid_range.json @@ -0,0 +1,9 @@ +{ + "function": "browser::regex", + "case": "invalid_range", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "pattern": "[z-a]" + }, + "err": "bad character range z-a at position 1" +} diff --git a/browser/tests/golden/behavior/regex/invalid_unterminated_group.json b/browser/tests/golden/behavior/regex/invalid_unterminated_group.json new file mode 100644 index 000000000..9c4152ccc --- /dev/null +++ b/browser/tests/golden/behavior/regex/invalid_unterminated_group.json @@ -0,0 +1,9 @@ +{ + "function": "browser::regex", + "case": "invalid_unterminated_group", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "pattern": "(" + }, + "err": "missing ), unterminated subpattern at position 0" +} diff --git a/browser/tests/golden/behavior/regex/invalid_variable_lookbehind.json b/browser/tests/golden/behavior/regex/invalid_variable_lookbehind.json new file mode 100644 index 000000000..e941fafea --- /dev/null +++ b/browser/tests/golden/behavior/regex/invalid_variable_lookbehind.json @@ -0,0 +1,9 @@ +{ + "function": "browser::regex", + "case": "invalid_variable_lookbehind", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "pattern": "(?<=a*)b" + }, + "err": "look-behind requires fixed-width pattern" +} diff --git a/browser/tests/golden/behavior/regex/lookbehind_and_conditional.json b/browser/tests/golden/behavior/regex/lookbehind_and_conditional.json new file mode 100644 index 000000000..cbc12184a --- /dev/null +++ b/browser/tests/golden/behavior/regex/lookbehind_and_conditional.json @@ -0,0 +1,22 @@ +{ + "function": "browser::regex", + "case": "lookbehind_and_conditional", + "request": { + "html": "<p>abcdef ab c</p>", + "pattern": "(?:(?<=abc)def)|((a)?(?(2)b|c))" + }, + "ok": { + "result": [ + "ab", + "a", + "c", + "", + "", + "", + "ab", + "a", + "c", + "" + ] + } +} diff --git a/browser/tests/golden/behavior/regex/named_backreference.json b/browser/tests/golden/behavior/regex/named_backreference.json new file mode 100644 index 000000000..e1de63271 --- /dev/null +++ b/browser/tests/golden/behavior/regex/named_backreference.json @@ -0,0 +1,13 @@ +{ + "function": "browser::regex", + "case": "named_backreference", + "request": { + "html": "<p>ab-ab ab-ac</p>", + "pattern": "(?P<word>ab)-(?P=word)" + }, + "ok": { + "result": [ + "ab" + ] + } +} diff --git a/browser/tests/golden/behavior/regex/no_match_all.json b/browser/tests/golden/behavior/regex/no_match_all.json new file mode 100644 index 000000000..31d4a238f --- /dev/null +++ b/browser/tests/golden/behavior/regex/no_match_all.json @@ -0,0 +1,11 @@ +{ + "function": "browser::regex", + "case": "no_match_all", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "pattern": "zzz" + }, + "ok": { + "result": [] + } +} diff --git a/browser/tests/golden/behavior/regex/no_match_first.json b/browser/tests/golden/behavior/regex/no_match_first.json new file mode 100644 index 000000000..c98fa06e2 --- /dev/null +++ b/browser/tests/golden/behavior/regex/no_match_first.json @@ -0,0 +1,12 @@ +{ + "function": "browser::regex", + "case": "no_match_first", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "pattern": "zzz", + "first": true + }, + "ok": { + "result": null + } +} diff --git a/browser/tests/golden/behavior/regex/smoke_all.json b/browser/tests/golden/behavior/regex/smoke_all.json new file mode 100644 index 000000000..8687ba3f2 --- /dev/null +++ b/browser/tests/golden/behavior/regex/smoke_all.json @@ -0,0 +1,14 @@ +{ + "function": "browser::regex", + "case": "smoke_all", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "pattern": "\\d+" + }, + "ok": { + "result": [ + "42", + "99" + ] + } +} diff --git a/browser/tests/golden/behavior/regex/two_groups.json b/browser/tests/golden/behavior/regex/two_groups.json new file mode 100644 index 000000000..ff080aa5b --- /dev/null +++ b/browser/tests/golden/behavior/regex/two_groups.json @@ -0,0 +1,16 @@ +{ + "function": "browser::regex", + "case": "two_groups", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "pattern": "(\\w+) (\\d+)" + }, + "ok": { + "result": [ + "price", + "42", + "then", + "99" + ] + } +} diff --git a/browser/tests/golden/behavior/regex/unicode_name_escape.json b/browser/tests/golden/behavior/regex/unicode_name_escape.json new file mode 100644 index 000000000..c266ef7f6 --- /dev/null +++ b/browser/tests/golden/behavior/regex/unicode_name_escape.json @@ -0,0 +1,13 @@ +{ + "function": "browser::regex", + "case": "unicode_name_escape", + "request": { + "html": "<p>A—B</p>", + "pattern": "\\N{EM DASH}" + }, + "ok": { + "result": [ + "—" + ] + } +} diff --git a/browser/tests/golden/behavior/regex/w3lib_html4_entities.json b/browser/tests/golden/behavior/regex/w3lib_html4_entities.json new file mode 100644 index 000000000..a65dcfd51 --- /dev/null +++ b/browser/tests/golden/behavior/regex/w3lib_html4_entities.json @@ -0,0 +1,18 @@ +{ + "function": "browser::regex", + "case": "w3lib_html4_entities", + "request": { + "html": "<p>&amp;apos; &amp;Copy; &amp;#128; &amp;#129; &amp;unknown; &amp;unknown</p>", + "pattern": "&(?:[A-Za-z]+|#[0-9]+);?" + }, + "ok": { + "result": [ + "", + "©", + "€", + "", + "", + "&unknown" + ] + } +} diff --git a/browser/tests/golden/behavior/regex/zero_width_findall.json b/browser/tests/golden/behavior/regex/zero_width_findall.json new file mode 100644 index 000000000..8805c5090 --- /dev/null +++ b/browser/tests/golden/behavior/regex/zero_width_findall.json @@ -0,0 +1,15 @@ +{ + "function": "browser::regex", + "case": "zero_width_findall", + "request": { + "html": "<p>ab</p>", + "pattern": "x*" + }, + "ok": { + "result": [ + "", + "", + "" + ] + } +} diff --git a/browser/tests/golden/behavior/to-markdown/bad_format.json b/browser/tests/golden/behavior/to-markdown/bad_format.json new file mode 100644 index 000000000..2115a92e0 --- /dev/null +++ b/browser/tests/golden/behavior/to-markdown/bad_format.json @@ -0,0 +1,9 @@ +{ + "function": "browser::to-markdown", + "case": "bad_format", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "format": "pdf" + }, + "err": "unsupported format: pdf" +} diff --git a/browser/tests/golden/behavior/to-markdown/hidden_body_self_exempt.json b/browser/tests/golden/behavior/to-markdown/hidden_body_self_exempt.json new file mode 100644 index 000000000..c0a886413 --- /dev/null +++ b/browser/tests/golden/behavior/to-markdown/hidden_body_self_exempt.json @@ -0,0 +1,13 @@ +{ + "function": "browser::to-markdown", + "case": "hidden_body_self_exempt", + "request": { + "html": "<html><head></head><body aria-hidden=\"true\"><p>keep me</p></body></html>", + "format": "text", + "main_content_only": true + }, + "ok": { + "format": "text", + "content": "keep me" + } +} diff --git a/browser/tests/golden/behavior/to-markdown/html_roundtrip.json b/browser/tests/golden/behavior/to-markdown/html_roundtrip.json new file mode 100644 index 000000000..86d20ab63 --- /dev/null +++ b/browser/tests/golden/behavior/to-markdown/html_roundtrip.json @@ -0,0 +1,12 @@ +{ + "function": "browser::to-markdown", + "case": "html_roundtrip", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "format": "html" + }, + "ok": { + "format": "html", + "content": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>" + } +} diff --git a/browser/tests/golden/behavior/to-markdown/markdown_basic.json b/browser/tests/golden/behavior/to-markdown/markdown_basic.json new file mode 100644 index 000000000..33d96c80d --- /dev/null +++ b/browser/tests/golden/behavior/to-markdown/markdown_basic.json @@ -0,0 +1,11 @@ +{ + "function": "browser::to-markdown", + "case": "markdown_basic", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n" + }, + "ok": { + "format": "markdown", + "content": "Hello\n=====\n\n* [Apple](/a)\n* [Banana](/b)\n\nprice 42 usd then 99" + } +} diff --git a/browser/tests/golden/behavior/to-markdown/markdown_blocks_and_lists.json b/browser/tests/golden/behavior/to-markdown/markdown_blocks_and_lists.json new file mode 100644 index 000000000..003bf7325 --- /dev/null +++ b/browser/tests/golden/behavior/to-markdown/markdown_blocks_and_lists.json @@ -0,0 +1,11 @@ +{ + "function": "browser::to-markdown", + "case": "markdown_blocks_and_lists", + "request": { + "html": "\n <div><blockquote><p>one<br>two</p><ul><li>A<ul><li>B<ul><li>C</li></ul></li></ul></li><li>D</li></ul></blockquote>\n <ol start=\"3\"><li>Three</li><li><p>Four</p></li></ol>\n <dl><dt> Term one </dt><dd>definition\nline</dd><dt>Next</dt><dd><b>bold</b></dd></dl>\n <pre>\n a * b\n`tick`\n</pre></div><p>after</p>\n " + }, + "ok": { + "format": "markdown", + "content": "> one \n> two\n>\n> * A\n> + B\n> - C\n> * D\n\n3. Three\n4. Four\n\nTerm one\n: definition\n line\n\nNext\n: **bold**\n\n```\n a * b\n`tick`\n```\n\nafter" + } +} diff --git a/browser/tests/golden/behavior/to-markdown/markdown_html_parser_second_parse.json b/browser/tests/golden/behavior/to-markdown/markdown_html_parser_second_parse.json new file mode 100644 index 000000000..1229435e6 --- /dev/null +++ b/browser/tests/golden/behavior/to-markdown/markdown_html_parser_second_parse.json @@ -0,0 +1,11 @@ +{ + "function": "browser::to-markdown", + "case": "markdown_html_parser_second_parse", + "request": { + "html": "<p>before</p><plaintext><b>looks bold</b><p>tail</p></plaintext><p>after</p>" + }, + "ok": { + "format": "markdown", + "content": "before\n\n&lt;b&gt;looks bold&lt;/b&gt;&lt;p&gt;tail&lt;/p&gt;&lt;/plaintext&gt;&lt;p&gt;after&lt;/p&gt;</plaintext></body></html>" + } +} diff --git a/browser/tests/golden/behavior/to-markdown/markdown_inline_defaults.json b/browser/tests/golden/behavior/to-markdown/markdown_inline_defaults.json new file mode 100644 index 000000000..2241f6ad6 --- /dev/null +++ b/browser/tests/golden/behavior/to-markdown/markdown_inline_defaults.json @@ -0,0 +1,11 @@ +{ + "function": "browser::to-markdown", + "case": "markdown_inline_defaults", + "request": { + "html": "\n <h1> A *title* </h1><h2>Sub_head</h2><h3>Third\n head</h3>\n <p><strong> bold </strong> <em>em</em> <del>gone</del>\n <code>a``b</code><br><q>quote</q> H<sub>2</sub>O x<sup>2</sup></p>\n <p><a href=\"https://e.test/a_b\">https://e.test/a_b</a>\n <a href=\"/x\" title='a\"b'> Link </a>\n <img alt=\"A\" src=\"i.png\" title='t\"x'></p><hr>\n " + }, + "ok": { + "format": "markdown", + "content": "A \\*title\\*\n===========\n\nSub\\_head\n---------\n\n### Third head\n\n**bold** *em* ~~gone~~\n``` a``b ``` \n\"quote\" H2O x2\n\n<https://e.test/a_b>\n [Link](/x \"a\\\"b\") \n![A](i.png \"t\\\"x\")\n\n---" + } +} diff --git a/browser/tests/golden/behavior/to-markdown/markdown_table_and_video.json b/browser/tests/golden/behavior/to-markdown/markdown_table_and_video.json new file mode 100644 index 000000000..3521a364c --- /dev/null +++ b/browser/tests/golden/behavior/to-markdown/markdown_table_and_video.json @@ -0,0 +1,11 @@ +{ + "function": "browser::to-markdown", + "case": "markdown_table_and_video", + "request": { + "html": "\n <figure><table><caption> Cap </caption><thead><tr><th colspan=\"2\">Head <img alt=\"ALT\" src=\"x\"></th></tr></thead>\n <tbody><tr><td>A</td><td><b>B</b><br>C</td></tr></tbody></table>\n <figcaption> Figure text </figcaption></figure>\n <video poster=\"poster.jpg\"><source src=\"movie.mp4\">Trailer</video><video src=\"only.mp4\">Only</video>\n " + }, + "ok": { + "format": "markdown", + "content": "Cap\n\n| Head ALT | |\n| --- | --- |\n| A | **B** C |\n\nFigure text\n\n[![Trailer](poster.jpg)](movie.mp4)[Only](only.mp4)" + } +} diff --git a/browser/tests/golden/behavior/to-markdown/markdown_unknown_and_noise_tags.json b/browser/tests/golden/behavior/to-markdown/markdown_unknown_and_noise_tags.json new file mode 100644 index 000000000..fce134481 --- /dev/null +++ b/browser/tests/golden/behavior/to-markdown/markdown_unknown_and_noise_tags.json @@ -0,0 +1,11 @@ +{ + "function": "browser::to-markdown", + "case": "markdown_unknown_and_noise_tags", + "request": { + "html": "\n <!doctype html><!--before--><main data-x=\"1\"><custom-tag> alpha beta\n gamma </custom-tag>\n <p>before <span>mid</span> after</p><script>bad()</script><style>x{}</style>\n <template><p>T</p></template></main>\n " + }, + "ok": { + "format": "markdown", + "content": "alpha beta\ngamma \n\nbefore mid after\n\nT" + } +} diff --git a/browser/tests/golden/behavior/to-markdown/pseudo_text_selector_html_mode.json b/browser/tests/golden/behavior/to-markdown/pseudo_text_selector_html_mode.json new file mode 100644 index 000000000..7edcf2e2d --- /dev/null +++ b/browser/tests/golden/behavior/to-markdown/pseudo_text_selector_html_mode.json @@ -0,0 +1,13 @@ +{ + "function": "browser::to-markdown", + "case": "pseudo_text_selector_html_mode", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "format": "html", + "css_selector": "li a::text" + }, + "ok": { + "format": "html", + "content": "AppleBanana" + } +} diff --git a/browser/tests/golden/behavior/to-markdown/pseudo_text_selector_text_mode.json b/browser/tests/golden/behavior/to-markdown/pseudo_text_selector_text_mode.json new file mode 100644 index 000000000..170a4dc34 --- /dev/null +++ b/browser/tests/golden/behavior/to-markdown/pseudo_text_selector_text_mode.json @@ -0,0 +1,13 @@ +{ + "function": "browser::to-markdown", + "case": "pseudo_text_selector_text_mode", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "format": "text", + "css_selector": "li a::text" + }, + "ok": { + "format": "text", + "content": "AppleBanana" + } +} diff --git a/browser/tests/golden/behavior/to-markdown/scoped_css.json b/browser/tests/golden/behavior/to-markdown/scoped_css.json new file mode 100644 index 000000000..caff64c32 --- /dev/null +++ b/browser/tests/golden/behavior/to-markdown/scoped_css.json @@ -0,0 +1,13 @@ +{ + "function": "browser::to-markdown", + "case": "scoped_css", + "request": { + "html": "<html><head><title>Messy Page</title><meta charset=\"utf-8\"></head><body>\n<nav><ul><li><a href=\"/home\">Home</a></li><li><a href=\"/about\">About</a></li></ul></nav>\n<template><p>never render this</p></template>\n<div aria-hidden=\"true\">screen-reader trap</div>\n<div style=\"display:none\">hidden A</div>\n<div style=\"visibility: hidden\">hidden B</div>\n<main>\n <h1>Widget Review</h1>\n <p>Intro paragraph with <em>emphasis</em> and a <a href=\"/w/1\">link</a>.</p>\n <h2>Specs</h2>\n <table><tr><th>Name</th><th>Value</th></tr><tr><td>Weight</td><td>3kg</td></tr></table>\n <ul><li>alpha<ul><li>nested</li></ul></li><li>beta</li></ul>\n <div class=\"card\" data-id=\"1\"><h3>Card One</h3><p>first card</p></div>\n <div class=\"card\" data-id=\"2\"><h3>Card Two</h3><p>second card</p></div>\n <div class=\"card wide\" data-id=\"3\"><h3>Card Three</h3><p>third card</p></div>\n</main>\n<footer><p>© 2026 Example — <span style=\"font-size:0\">invisible</span>fine print</p></footer>\n<script>analytics()</script>\n</body></html>\n", + "format": "text", + "css_selector": "div.card" + }, + "ok": { + "format": "text", + "content": "Card One\nfirst cardCard Two\nsecond cardCard Three\nthird card" + } +} diff --git a/browser/tests/golden/behavior/to-markdown/text_basic.json b/browser/tests/golden/behavior/to-markdown/text_basic.json new file mode 100644 index 000000000..62e43c1b2 --- /dev/null +++ b/browser/tests/golden/behavior/to-markdown/text_basic.json @@ -0,0 +1,12 @@ +{ + "function": "browser::to-markdown", + "case": "text_basic", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "format": "text" + }, + "ok": { + "format": "text", + "content": "Hello\nApple\nBanana\nprice 42 usd then 99" + } +} diff --git a/browser/tests/golden/behavior/to-markdown/text_messy_main_only.json b/browser/tests/golden/behavior/to-markdown/text_messy_main_only.json new file mode 100644 index 000000000..26735b811 --- /dev/null +++ b/browser/tests/golden/behavior/to-markdown/text_messy_main_only.json @@ -0,0 +1,13 @@ +{ + "function": "browser::to-markdown", + "case": "text_messy_main_only", + "request": { + "html": "<html><head><title>Messy Page</title><meta charset=\"utf-8\"></head><body>\n<nav><ul><li><a href=\"/home\">Home</a></li><li><a href=\"/about\">About</a></li></ul></nav>\n<template><p>never render this</p></template>\n<div aria-hidden=\"true\">screen-reader trap</div>\n<div style=\"display:none\">hidden A</div>\n<div style=\"visibility: hidden\">hidden B</div>\n<main>\n <h1>Widget Review</h1>\n <p>Intro paragraph with <em>emphasis</em> and a <a href=\"/w/1\">link</a>.</p>\n <h2>Specs</h2>\n <table><tr><th>Name</th><th>Value</th></tr><tr><td>Weight</td><td>3kg</td></tr></table>\n <ul><li>alpha<ul><li>nested</li></ul></li><li>beta</li></ul>\n <div class=\"card\" data-id=\"1\"><h3>Card One</h3><p>first card</p></div>\n <div class=\"card\" data-id=\"2\"><h3>Card Two</h3><p>second card</p></div>\n <div class=\"card wide\" data-id=\"3\"><h3>Card Three</h3><p>third card</p></div>\n</main>\n<footer><p>© 2026 Example — <span style=\"font-size:0\">invisible</span>fine print</p></footer>\n<script>analytics()</script>\n</body></html>\n", + "format": "text", + "main_content_only": true + }, + "ok": { + "format": "text", + "content": "Home\nAbout\nWidget Review\nIntro paragraph with\nemphasis\nand a\nlink\n.\nSpecs\nName\nValue\nWeight\n3kg\nalpha\nnested\nbeta\nCard One\nfirst card\nCard Two\nsecond card\nCard Three\nthird card\n© 2026 Example — fine print" + } +} diff --git a/browser/tests/golden/behavior/xpath/all_anchors_text.json b/browser/tests/golden/behavior/xpath/all_anchors_text.json new file mode 100644 index 000000000..8ab8929c8 --- /dev/null +++ b/browser/tests/golden/behavior/xpath/all_anchors_text.json @@ -0,0 +1,14 @@ +{ + "function": "browser::xpath", + "case": "all_anchors_text", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "//ul/li/a" + }, + "ok": { + "result": [ + "Apple", + "Banana" + ] + } +} diff --git a/browser/tests/golden/behavior/xpath/ancestor_axis_reverse_position.json b/browser/tests/golden/behavior/xpath/ancestor_axis_reverse_position.json new file mode 100644 index 000000000..0891ab38a --- /dev/null +++ b/browser/tests/golden/behavior/xpath/ancestor_axis_reverse_position.json @@ -0,0 +1,13 @@ +{ + "function": "browser::xpath", + "case": "ancestor_axis_reverse_position", + "request": { + "html": "<main id='m'><section id='s'><p>P</p></section></main>", + "query": "//p/ancestor::*[1]/@id" + }, + "ok": { + "result": [ + "s" + ] + } +} diff --git a/browser/tests/golden/behavior/xpath/attr_axis_terminal.json b/browser/tests/golden/behavior/xpath/attr_axis_terminal.json new file mode 100644 index 000000000..192d1121b --- /dev/null +++ b/browser/tests/golden/behavior/xpath/attr_axis_terminal.json @@ -0,0 +1,14 @@ +{ + "function": "browser::xpath", + "case": "attr_axis_terminal", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "//a/@href" + }, + "ok": { + "result": [ + "/a", + "/b" + ] + } +} diff --git a/browser/tests/golden/behavior/xpath/attr_param_on_elements.json b/browser/tests/golden/behavior/xpath/attr_param_on_elements.json new file mode 100644 index 000000000..9b92491d7 --- /dev/null +++ b/browser/tests/golden/behavior/xpath/attr_param_on_elements.json @@ -0,0 +1,15 @@ +{ + "function": "browser::xpath", + "case": "attr_param_on_elements", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "//li/a", + "attr": "href" + }, + "ok": { + "result": [ + "/a", + "/b" + ] + } +} diff --git a/browser/tests/golden/behavior/xpath/attribute_wildcard_order.json b/browser/tests/golden/behavior/xpath/attribute_wildcard_order.json new file mode 100644 index 000000000..b04f0413a --- /dev/null +++ b/browser/tests/golden/behavior/xpath/attribute_wildcard_order.json @@ -0,0 +1,15 @@ +{ + "function": "browser::xpath", + "case": "attribute_wildcard_order", + "request": { + "html": "<p z='1' a='2' m='3'>P</p>", + "query": "//p/@*" + }, + "ok": { + "result": [ + "1", + "2", + "3" + ] + } +} diff --git a/browser/tests/golden/behavior/xpath/contains_href.json b/browser/tests/golden/behavior/xpath/contains_href.json new file mode 100644 index 000000000..a5a80db0e --- /dev/null +++ b/browser/tests/golden/behavior/xpath/contains_href.json @@ -0,0 +1,12 @@ +{ + "function": "browser::xpath", + "case": "contains_href", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "//a[contains(@href, 'b')]", + "first": true + }, + "ok": { + "result": "Banana" + } +} diff --git a/browser/tests/golden/behavior/xpath/explicit_axis_after_slashslash.json b/browser/tests/golden/behavior/xpath/explicit_axis_after_slashslash.json new file mode 100644 index 000000000..25b54b1b3 --- /dev/null +++ b/browser/tests/golden/behavior/xpath/explicit_axis_after_slashslash.json @@ -0,0 +1,13 @@ +{ + "function": "browser::xpath", + "case": "explicit_axis_after_slashslash", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "//descendant::li[2]" + }, + "ok": { + "result": [ + "Banana" + ] + } +} diff --git a/browser/tests/golden/behavior/xpath/false_scalar_becomes_empty.json b/browser/tests/golden/behavior/xpath/false_scalar_becomes_empty.json new file mode 100644 index 000000000..e630c84e4 --- /dev/null +++ b/browser/tests/golden/behavior/xpath/false_scalar_becomes_empty.json @@ -0,0 +1,11 @@ +{ + "function": "browser::xpath", + "case": "false_scalar_becomes_empty", + "request": { + "html": "<p>Alpha</p>", + "query": "boolean(//nope)" + }, + "ok": { + "result": [] + } +} diff --git a/browser/tests/golden/behavior/xpath/first_h1.json b/browser/tests/golden/behavior/xpath/first_h1.json new file mode 100644 index 000000000..a7f313c42 --- /dev/null +++ b/browser/tests/golden/behavior/xpath/first_h1.json @@ -0,0 +1,12 @@ +{ + "function": "browser::xpath", + "case": "first_h1", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "//h1", + "first": true + }, + "ok": { + "result": "Hello" + } +} diff --git a/browser/tests/golden/behavior/xpath/following_axis_document_order.json b/browser/tests/golden/behavior/xpath/following_axis_document_order.json new file mode 100644 index 000000000..5e2b76b59 --- /dev/null +++ b/browser/tests/golden/behavior/xpath/following_axis_document_order.json @@ -0,0 +1,14 @@ +{ + "function": "browser::xpath", + "case": "following_axis_document_order", + "request": { + "html": "<div><i>A</i></div><p>B</p><p>C</p>", + "query": "//i/following::p/text()" + }, + "ok": { + "result": [ + "B", + "C" + ] + } +} diff --git a/browser/tests/golden/behavior/xpath/global_parenthesized_position.json b/browser/tests/golden/behavior/xpath/global_parenthesized_position.json new file mode 100644 index 000000000..2db908b2f --- /dev/null +++ b/browser/tests/golden/behavior/xpath/global_parenthesized_position.json @@ -0,0 +1,13 @@ +{ + "function": "browser::xpath", + "case": "global_parenthesized_position", + "request": { + "html": "<div><p>A</p><p>B</p></div><section><p>C</p></section>", + "query": "(//p)[2]" + }, + "ok": { + "result": [ + "B" + ] + } +} diff --git a/browser/tests/golden/behavior/xpath/invalid_syntax.json b/browser/tests/golden/behavior/xpath/invalid_syntax.json new file mode 100644 index 000000000..7733eee75 --- /dev/null +++ b/browser/tests/golden/behavior/xpath/invalid_syntax.json @@ -0,0 +1,9 @@ +{ + "function": "browser::xpath", + "case": "invalid_syntax", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "//[" + }, + "err": "Invalid XPath selector: //[" +} diff --git a/browser/tests/golden/behavior/xpath/number_scalar_type_error.json b/browser/tests/golden/behavior/xpath/number_scalar_type_error.json new file mode 100644 index 000000000..1d68945d3 --- /dev/null +++ b/browser/tests/golden/behavior/xpath/number_scalar_type_error.json @@ -0,0 +1,9 @@ +{ + "function": "browser::xpath", + "case": "number_scalar_type_error", + "request": { + "html": "<p>Alpha</p>", + "query": "count(//p)" + }, + "err": "'float' object is not iterable" +} diff --git a/browser/tests/golden/behavior/xpath/positional.json b/browser/tests/golden/behavior/xpath/positional.json new file mode 100644 index 000000000..02857730f --- /dev/null +++ b/browser/tests/golden/behavior/xpath/positional.json @@ -0,0 +1,12 @@ +{ + "function": "browser::xpath", + "case": "positional", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "//li[2]/a", + "first": true + }, + "ok": { + "result": "Banana" + } +} diff --git a/browser/tests/golden/behavior/xpath/preceding_axis_reverse_position.json b/browser/tests/golden/behavior/xpath/preceding_axis_reverse_position.json new file mode 100644 index 000000000..7f95b3a1a --- /dev/null +++ b/browser/tests/golden/behavior/xpath/preceding_axis_reverse_position.json @@ -0,0 +1,13 @@ +{ + "function": "browser::xpath", + "case": "preceding_axis_reverse_position", + "request": { + "html": "<p id='a'>A</p><p id='b'>B</p><p id='c'>C</p>", + "query": "//p[@id='c']/preceding::p[1]/@id" + }, + "ok": { + "result": [ + "b" + ] + } +} diff --git a/browser/tests/golden/behavior/xpath/predicate_arithmetic_and_round.json b/browser/tests/golden/behavior/xpath/predicate_arithmetic_and_round.json new file mode 100644 index 000000000..90780b550 --- /dev/null +++ b/browser/tests/golden/behavior/xpath/predicate_arithmetic_and_round.json @@ -0,0 +1,13 @@ +{ + "function": "browser::xpath", + "case": "predicate_arithmetic_and_round", + "request": { + "html": "<i>1</i><i>2</i><i>3</i><i>4</i>", + "query": "//i[position() = round(last() div 2)]" + }, + "ok": { + "result": [ + "2" + ] + } +} diff --git a/browser/tests/golden/behavior/xpath/predicate_attr_value.json b/browser/tests/golden/behavior/xpath/predicate_attr_value.json new file mode 100644 index 000000000..3c2216478 --- /dev/null +++ b/browser/tests/golden/behavior/xpath/predicate_attr_value.json @@ -0,0 +1,14 @@ +{ + "function": "browser::xpath", + "case": "predicate_attr_value", + "request": { + "html": "<html><head><title>Messy Page</title><meta charset=\"utf-8\"></head><body>\n<nav><ul><li><a href=\"/home\">Home</a></li><li><a href=\"/about\">About</a></li></ul></nav>\n<template><p>never render this</p></template>\n<div aria-hidden=\"true\">screen-reader trap</div>\n<div style=\"display:none\">hidden A</div>\n<div style=\"visibility: hidden\">hidden B</div>\n<main>\n <h1>Widget Review</h1>\n <p>Intro paragraph with <em>emphasis</em> and a <a href=\"/w/1\">link</a>.</p>\n <h2>Specs</h2>\n <table><tr><th>Name</th><th>Value</th></tr><tr><td>Weight</td><td>3kg</td></tr></table>\n <ul><li>alpha<ul><li>nested</li></ul></li><li>beta</li></ul>\n <div class=\"card\" data-id=\"1\"><h3>Card One</h3><p>first card</p></div>\n <div class=\"card\" data-id=\"2\"><h3>Card Two</h3><p>second card</p></div>\n <div class=\"card wide\" data-id=\"3\"><h3>Card Three</h3><p>third card</p></div>\n</main>\n<footer><p>© 2026 Example — <span style=\"font-size:0\">invisible</span>fine print</p></footer>\n<script>analytics()</script>\n</body></html>\n", + "query": "//div[@class='card']" + }, + "ok": { + "result": [ + "Card One\nfirst card", + "Card Two\nsecond card" + ] + } +} diff --git a/browser/tests/golden/behavior/xpath/predicate_string_functions.json b/browser/tests/golden/behavior/xpath/predicate_string_functions.json new file mode 100644 index 000000000..11112e3fa --- /dev/null +++ b/browser/tests/golden/behavior/xpath/predicate_string_functions.json @@ -0,0 +1,13 @@ +{ + "function": "browser::xpath", + "case": "predicate_string_functions", + "request": { + "html": "<p> Alpha beta </p><p>Gamma</p>", + "query": "//p[starts-with(normalize-space(.), 'Alpha') and string-length(normalize-space(.)) = 10]" + }, + "ok": { + "result": [ + " Alpha beta " + ] + } +} diff --git a/browser/tests/golden/behavior/xpath/string_scalar_splits_into_text_nodes.json b/browser/tests/golden/behavior/xpath/string_scalar_splits_into_text_nodes.json new file mode 100644 index 000000000..6d62f1850 --- /dev/null +++ b/browser/tests/golden/behavior/xpath/string_scalar_splits_into_text_nodes.json @@ -0,0 +1,9 @@ +{ + "function": "browser::xpath", + "case": "string_scalar_splits_into_text_nodes", + "request": { + "html": "<p>Alpha</p>", + "query": "string(//p)" + }, + "err": "'str' object has no attribute 'iter'" +} diff --git a/browser/tests/golden/behavior/xpath/template_child_step.json b/browser/tests/golden/behavior/xpath/template_child_step.json new file mode 100644 index 000000000..3dccc6297 --- /dev/null +++ b/browser/tests/golden/behavior/xpath/template_child_step.json @@ -0,0 +1,13 @@ +{ + "function": "browser::xpath", + "case": "template_child_step", + "request": { + "html": "<html><head><title>Messy Page</title><meta charset=\"utf-8\"></head><body>\n<nav><ul><li><a href=\"/home\">Home</a></li><li><a href=\"/about\">About</a></li></ul></nav>\n<template><p>never render this</p></template>\n<div aria-hidden=\"true\">screen-reader trap</div>\n<div style=\"display:none\">hidden A</div>\n<div style=\"visibility: hidden\">hidden B</div>\n<main>\n <h1>Widget Review</h1>\n <p>Intro paragraph with <em>emphasis</em> and a <a href=\"/w/1\">link</a>.</p>\n <h2>Specs</h2>\n <table><tr><th>Name</th><th>Value</th></tr><tr><td>Weight</td><td>3kg</td></tr></table>\n <ul><li>alpha<ul><li>nested</li></ul></li><li>beta</li></ul>\n <div class=\"card\" data-id=\"1\"><h3>Card One</h3><p>first card</p></div>\n <div class=\"card\" data-id=\"2\"><h3>Card Two</h3><p>second card</p></div>\n <div class=\"card wide\" data-id=\"3\"><h3>Card Three</h3><p>third card</p></div>\n</main>\n<footer><p>© 2026 Example — <span style=\"font-size:0\">invisible</span>fine print</p></footer>\n<script>analytics()</script>\n</body></html>\n", + "query": "//template/p" + }, + "ok": { + "result": [ + "never render this" + ] + } +} diff --git a/browser/tests/golden/behavior/xpath/text_runs_body_messy.json b/browser/tests/golden/behavior/xpath/text_runs_body_messy.json new file mode 100644 index 000000000..01ee62af1 --- /dev/null +++ b/browser/tests/golden/behavior/xpath/text_runs_body_messy.json @@ -0,0 +1,16 @@ +{ + "function": "browser::xpath", + "case": "text_runs_body_messy", + "request": { + "html": "<html><head><title>Messy Page</title><meta charset=\"utf-8\"></head><body>\n<nav><ul><li><a href=\"/home\">Home</a></li><li><a href=\"/about\">About</a></li></ul></nav>\n<template><p>never render this</p></template>\n<div aria-hidden=\"true\">screen-reader trap</div>\n<div style=\"display:none\">hidden A</div>\n<div style=\"visibility: hidden\">hidden B</div>\n<main>\n <h1>Widget Review</h1>\n <p>Intro paragraph with <em>emphasis</em> and a <a href=\"/w/1\">link</a>.</p>\n <h2>Specs</h2>\n <table><tr><th>Name</th><th>Value</th></tr><tr><td>Weight</td><td>3kg</td></tr></table>\n <ul><li>alpha<ul><li>nested</li></ul></li><li>beta</li></ul>\n <div class=\"card\" data-id=\"1\"><h3>Card One</h3><p>first card</p></div>\n <div class=\"card\" data-id=\"2\"><h3>Card Two</h3><p>second card</p></div>\n <div class=\"card wide\" data-id=\"3\"><h3>Card Three</h3><p>third card</p></div>\n</main>\n<footer><p>© 2026 Example — <span style=\"font-size:0\">invisible</span>fine print</p></footer>\n<script>analytics()</script>\n</body></html>\n", + "query": "//body/text()" + }, + "ok": { + "result": [ + "\n", + "\n", + "\n", + "\n" + ] + } +} diff --git a/browser/tests/golden/behavior/xpath/text_runs_main_messy.json b/browser/tests/golden/behavior/xpath/text_runs_main_messy.json new file mode 100644 index 000000000..6c7204787 --- /dev/null +++ b/browser/tests/golden/behavior/xpath/text_runs_main_messy.json @@ -0,0 +1,18 @@ +{ + "function": "browser::xpath", + "case": "text_runs_main_messy", + "request": { + "html": "<html><head><title>Messy Page</title><meta charset=\"utf-8\"></head><body>\n<nav><ul><li><a href=\"/home\">Home</a></li><li><a href=\"/about\">About</a></li></ul></nav>\n<template><p>never render this</p></template>\n<div aria-hidden=\"true\">screen-reader trap</div>\n<div style=\"display:none\">hidden A</div>\n<div style=\"visibility: hidden\">hidden B</div>\n<main>\n <h1>Widget Review</h1>\n <p>Intro paragraph with <em>emphasis</em> and a <a href=\"/w/1\">link</a>.</p>\n <h2>Specs</h2>\n <table><tr><th>Name</th><th>Value</th></tr><tr><td>Weight</td><td>3kg</td></tr></table>\n <ul><li>alpha<ul><li>nested</li></ul></li><li>beta</li></ul>\n <div class=\"card\" data-id=\"1\"><h3>Card One</h3><p>first card</p></div>\n <div class=\"card\" data-id=\"2\"><h3>Card Two</h3><p>second card</p></div>\n <div class=\"card wide\" data-id=\"3\"><h3>Card Three</h3><p>third card</p></div>\n</main>\n<footer><p>© 2026 Example — <span style=\"font-size:0\">invisible</span>fine print</p></footer>\n<script>analytics()</script>\n</body></html>\n", + "query": "//main/text()" + }, + "ok": { + "result": [ + "\n ", + "\n ", + "\n ", + "\n ", + "\n ", + "\n" + ] + } +} diff --git a/browser/tests/golden/behavior/xpath/text_terminal.json b/browser/tests/golden/behavior/xpath/text_terminal.json new file mode 100644 index 000000000..00e7eb742 --- /dev/null +++ b/browser/tests/golden/behavior/xpath/text_terminal.json @@ -0,0 +1,12 @@ +{ + "function": "browser::xpath", + "case": "text_terminal", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "//h1/text()", + "first": true + }, + "ok": { + "result": "Hello" + } +} diff --git a/browser/tests/golden/behavior/xpath/textarea_blank_body_kept.json b/browser/tests/golden/behavior/xpath/textarea_blank_body_kept.json new file mode 100644 index 000000000..b78f2968a --- /dev/null +++ b/browser/tests/golden/behavior/xpath/textarea_blank_body_kept.json @@ -0,0 +1,13 @@ +{ + "function": "browser::xpath", + "case": "textarea_blank_body_kept", + "request": { + "html": "<html><head></head><body>\n<div id=\"wrap\" class=\"outer main\">\n <span>CONDITION: <!-- separator -->Excellent</span>\n <p> <b>bold</b>after-bold</p>\n <p>lead<b>mid</b>tail</p>\n <ul><li>one</li><li>two</li><li>three</li></ul>\n <a href=\"/x?a=1&amp;b=2\" data-price=\"10\">A&amp;B</a>\n <input disabled type=\"text\">\n <script>var hidden = \"never\";</script>\n <style>.x { display: none; }</style>\n <p class=\"uni\">Ünïcode — café</p>\n <textarea> </textarea>\n <div>line one\nand two <b>x</b></div>\n <div data-x=\"1\">line oneand two <b>y</b></div>\n</div>\n</body></html>\n", + "query": "//textarea/text()" + }, + "ok": { + "result": [ + " " + ] + } +} diff --git a/browser/tests/golden/behavior/xpath/true_scalar_type_error.json b/browser/tests/golden/behavior/xpath/true_scalar_type_error.json new file mode 100644 index 000000000..17cfa326b --- /dev/null +++ b/browser/tests/golden/behavior/xpath/true_scalar_type_error.json @@ -0,0 +1,9 @@ +{ + "function": "browser::xpath", + "case": "true_scalar_type_error", + "request": { + "html": "<p>Alpha</p>", + "query": "boolean(//p)" + }, + "err": "'bool' object is not iterable" +} diff --git a/browser/tests/golden/behavior/xpath/union_doc_order.json b/browser/tests/golden/behavior/xpath/union_doc_order.json new file mode 100644 index 000000000..1f553b2d9 --- /dev/null +++ b/browser/tests/golden/behavior/xpath/union_doc_order.json @@ -0,0 +1,14 @@ +{ + "function": "browser::xpath", + "case": "union_doc_order", + "request": { + "html": "<html><head></head><body><h1 class=\"t\">Hello</h1><ul><li><a href=\"/a\">Apple</a></li><li><a href=\"/b\">Banana</a></li></ul><p>price 42 usd then 99</p></body></html>\n", + "query": "//h1 | //p" + }, + "ok": { + "result": [ + "Hello", + "price 42 usd then 99" + ] + } +} diff --git a/browser/tests/golden/behavior/xpath/unknown_function_error.json b/browser/tests/golden/behavior/xpath/unknown_function_error.json new file mode 100644 index 000000000..ca498550a --- /dev/null +++ b/browser/tests/golden/behavior/xpath/unknown_function_error.json @@ -0,0 +1,9 @@ +{ + "function": "browser::xpath", + "case": "unknown_function_error", + "request": { + "html": "<p>Alpha</p>", + "query": "no-such-function()" + }, + "err": "Invalid XPath selector: no-such-function()" +} diff --git a/browser/tests/golden/browser/dynamic-full-png-1.png b/browser/tests/golden/browser/dynamic-full-png-1.png new file mode 100644 index 0000000000000000000000000000000000000000..bfbd5df941cbedebf99df8793265c31954fc3c9c GIT binary patch literal 7554 zcmeAS@N?(olHy`uVBq!ia0y~yU;#2&7jQ5EDSwe=eGCk;o}Mm_Ar*7p-dX7DYRJ%b zv3z0Ua)AWNW&stk<`i+a!wY6G92c@^QYg5&Nc6s?@Sjcbv&wewsk8!_slJ*WNPXr2 z5*&g+LO=;fD7XL#hYldoAixMD6+xCcfh=wrRX7?vqlscPD~y&D16e+v_;GjpRcT=H z?0M|!&-1T%85j<zJpTHx_A46$LxPYwu%H%r1QcXw`N0Y-ut!U7P=Jh9Y@^lrXjT|) z;|<o9t>p4{NclbKV`b5{DrN=-gOe3|_Qmc6w!mi`0hi_#KtTq@I!30^cKK+XHd?2R zc2P$==D=Jqh{r9C?R&c#)Eb}M^Z0uqxN$z|?az0qqs{cuVI^Ry7#&Ol27<!qSR*hL zMhkLaAPnMy95LW0`FlQakfoukaL<n@aML{HcSUssMkC$t|LBmzK#q`&PF?^r)MzIO p7zm>Sf51Q(ow7lc;JAmt{s-o%f4bD5F%KAG44$rjF6*2UngGopbv*z8 literal 0 HcmV?d00001 diff --git a/browser/tests/golden/browser/dynamic-viewport-png-1.png b/browser/tests/golden/browser/dynamic-viewport-png-1.png new file mode 100644 index 0000000000000000000000000000000000000000..6ac53395d2e12bd91efc73ddfaff374852c2771e GIT binary patch literal 3069 zcmeAS@N?(olHy`uVBq!ia0y~yU;#3j95|SO)SU0lO$-d&M?75|Ln`LHy>pQFkbwxR zW0THPzO_plLL7IBI9hW|)O&OIT*!xYtM#j^W!6hGFkC2zw*_jR;SMAmI)Frj03(o8 z1exdrGQEWZNN@-O2>~S_q2K}}Miq_*&uF3;%?hI>#ekKM$KLN>dk<I?`xwppUH+Ag zfgxeiM?3q~=0KszJHSOWh$-+$Twye$j%L)+j5=T$b+ipN+HxeZy*z>2(ur4UyI1>b QU=hyX>FVdQ&MBb@0HVeQb^rhX literal 0 HcmV?d00001 diff --git a/browser/tests/golden/browser/manifest.json b/browser/tests/golden/browser/manifest.json new file mode 100644 index 000000000..c039ff61f --- /dev/null +++ b/browser/tests/golden/browser/manifest.json @@ -0,0 +1,128 @@ +{ + "cases": [ + { + "case": "dynamic-viewport-png", + "request": { + "url": "{origin}/visual", + "retries": 1, + "fetcher": "dynamic", + "format": "png", + "full_page": false + }, + "response": { + "content": [ + { + "type": "image", + "mime": "image/png", + "file": "dynamic-viewport-png-1.png", + "bytes": 3069, + "sha256": "9864b884e9e46dc3b367f3ca69677fa411c7cacb7967f01cb690ec6d75b06938", + "dimensions": [ + 1024, + 576 + ] + }, + { + "type": "text", + "text": "screenshot of {origin}/visual — 1024x576px, 1 tile(s), 3 KB" + } + ], + "url": "{origin}/visual", + "mime": "image/png" + } + }, + { + "case": "dynamic-full-png", + "request": { + "url": "{origin}/visual", + "retries": 1, + "fetcher": "dynamic", + "format": "png", + "full_page": true + }, + "response": { + "content": [ + { + "type": "image", + "mime": "image/png", + "file": "dynamic-full-png-1.png", + "bytes": 7554, + "sha256": "460c1cd20d3d8b4c231600cf4e25186612b37adc98c3610493e32729256530b6", + "dimensions": [ + 1024, + 1440 + ] + }, + { + "type": "text", + "text": "screenshot of {origin}/visual — 1024x1440px, 1 tile(s), 7 KB" + } + ], + "url": "{origin}/visual", + "mime": "image/png" + } + }, + { + "case": "stealthy-viewport-png", + "request": { + "url": "{origin}/visual", + "retries": 1, + "fetcher": "stealthy", + "format": "png", + "full_page": false + }, + "response": { + "content": [ + { + "type": "image", + "mime": "image/png", + "file": "stealthy-viewport-png-1.png", + "bytes": 3091, + "sha256": "24e279ef055905ecdcc441561e27cb2118352dfc6acee3298fcf951b25ec8a82", + "dimensions": [ + 1024, + 576 + ] + }, + { + "type": "text", + "text": "screenshot of {origin}/visual — 1024x576px, 1 tile(s), 3 KB" + } + ], + "url": "{origin}/visual", + "mime": "image/png" + } + }, + { + "case": "stealthy-full-jpeg", + "request": { + "url": "{origin}/visual", + "retries": 1, + "fetcher": "stealthy", + "format": "jpeg", + "full_page": true + }, + "response": { + "content": [ + { + "type": "image", + "mime": "image/jpeg", + "file": "stealthy-full-jpeg-1.jpg", + "bytes": 19067, + "sha256": "aee1bf8560648030c76b3052a4a26eb782808fc24ea50315bb7f4ade3a572903", + "dimensions": [ + 1024, + 960 + ] + }, + { + "type": "text", + "text": "screenshot of {origin}/visual — 1024x960px, 1 tile(s), 19 KB" + } + ], + "url": "{origin}/visual", + "mime": "image/jpeg" + } + } + ] +} diff --git a/browser/tests/golden/browser/stealthy-full-jpeg-1.jpg b/browser/tests/golden/browser/stealthy-full-jpeg-1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..91f6a3e5d583851a1a4770f849a6850906c536d1 GIT binary patch literal 19067 zcmeI&Sxgf_7zgm#?rygisii9-R?FROt&Nm=Mnu3C!I)S?MMC1O6=ULkU{J&Ztyera z)CXV-3MMKF2x?3KFT|LLC_+t)7mCIQtl+J67f}*l6kq#IGs!ly+w}YQH`#P{+%4|M zs9(G#&Vn!uA&h$=_d^teL_#4YBt;ZOiNzv`jP{aArLuwEN;y5)Pd#L?pGxH)Y|!`z z>Vs4&W|USxJj`S=sWoFFMjOW&!c9j0Lol&eER)K7y}W#l0jdCFpPRb|DMWY;fp9Dk z2^1Kvz}#1nn){w4*4<D~!vr`%3Mr9TBIQn~?1uyxjtdB!Bng5$JDnRxgo5-A2#XRb zt#hfs6|`~7fkIJG^x4|}35^}W;qz8ziX}b+d<Xi4glZVA&NO1=sL^A_#!NI@VkgDL zPfMIWV`kE<<oOF0E?R6`lDca3nzifJZ%Es^ZTpU_oxAJ@4;{|V$;~@*lywvpA3sr2 zdh%R(MdkUb>I)a^u3Wu#{YL%GhPzGo?l(VZX>~q%`s{hzi<j-MUcYs{>+E{};p3;z zd|en<=dbp&us`ZjaCHd?0w*ZGE=;hF>$rj-1Hy#fQC4d13T2>ii-?XsP<Xah92B0= z(SP2`Mu|_b=}E|2zO?Sj{;jag|ElbFVZC)ZkqpPU9}iccNaS)d?B$r-5{Auy4jF&} zFaQR?02lxRU;qqwgaPgHba|UaUih`7`b`L3JwD6jpzeH6Owq#|$N(JtZ7ysa-`F0h z8@WH~Q$pkHyp(Ucx@AMQx*WDnb@nhiMIYKbYOaGJNCRy2OqM=O2ij`(_lSEjtV6*% z92futU;qq&0WbgtdJNQeIf9z=PG{PU5w`lS0s|aC2>&i&-%?HUp8RCy%(+PAC0Ty5 zG2O;yzyXBN2MO^16P^cv0WbgtzyKHk17H9Qc#?rI8*Bg4)Om}ftrL<M)-I?u(^Gx; z1j?*tn#{B_1$yo|4K|0hGg3IomkR|!hYY{~7ytuc01SWuFyJ8uCRcn`H|#ytzFB9; zh}W*I;x^YhA7#1Nc7n}ang&CV2H5!j=^#&aMo?`QelKbA(2ir}7{B}0T9GDi$gvx0 zra10wO62$87PGs{l>9#2L9qp2OKt2MqW3c95?s`s9|X|@$+LqH*qI`XThM_H8Gr#W U00zJS7ytuc01SA70m|L_19cq%#{d8T literal 0 HcmV?d00001 diff --git a/browser/tests/golden/browser/stealthy-viewport-png-1.png b/browser/tests/golden/browser/stealthy-viewport-png-1.png new file mode 100644 index 0000000000000000000000000000000000000000..20a37712d7b77774d3e16c0e447ac7e35ed2e245 GIT binary patch literal 3091 zcmeAS@N?(olHy`uVBq!ia0y~yU;#3j95|SO)SU0lO$-d&w>(`OLn`LHy|Xcw$x+1h zVywi?ovts81PoOzinfM4v|GUt=`C)2&~|y#^tjryZ{n}t=Vf5XuvjYxG~$dhkWg>| z5)K_eqCtQWNGgI1cLG_|!T}^W1c8Kr5|9{GI2t^oiDJ-Yg~$JIgy#dZr<&yS^VeSi zGxq_N$A$l+_W~2^j3cZ-38y_kW`n?f2FB5BK3cc|6U1nd4h)2$R^vV1BfHuh=<dlq z&tE2gWn*ASnDp^;?PxY1bmj4A^J%n%p+-}A52KEn;FkVlg*Sk?p25@A&t;ucLK6V} CW*Bn- literal 0 HcmV?d00001 diff --git a/browser/tests/golden/schemas/browser.act.json b/browser/tests/golden/schemas/browser.act.json index 09762b3e9..ab11f08bb 100644 --- a/browser/tests/golden/schemas/browser.act.json +++ b/browser/tests/golden/schemas/browser.act.json @@ -1,51 +1,57 @@ { - "description": "Interact with the page: click (left/right/middle, single or double), hover, type, press, or scroll. Address elements with a [ref=eN] handle from browser::snapshot (or a pick), or raw viewport coordinates.", "function_id": "browser::act", + "description": "Interact with the page: click (left/right/middle, single or double), hover, type, press, or scroll. Address elements with a [ref=eN] handle from browser::snapshot (or a pick), or raw viewport coordinates.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ActInput", + "type": "object", + "required": [ + "action", + "session_id" + ], "properties": { "action": { "description": "`click`, `hover`, `type`, `press`, or `scroll`.", "type": "string" }, "button": { - "default": null, "description": "Mouse button for `click`: `left` (default), `right`, or `middle`.", + "default": null, "type": [ "string", "null" ] }, "click_count": { - "default": null, "description": "Clicks in the gesture: 2 double-clicks (`click` only, default 1).", - "format": "uint32", - "minimum": 0.0, + "default": null, "type": [ "integer", "null" - ] + ], + "format": "uint32", + "minimum": 0.0 }, "delta_y": { - "default": null, "description": "Scroll distance in pixels; positive scrolls down (`scroll`).", - "format": "double", + "default": null, "type": [ "number", "null" - ] + ], + "format": "double" }, "key": { - "default": null, "description": "Key name for `press`: Enter, Tab, Escape, Backspace, Delete, ArrowUp/Down/Left/Right, Home, End, PageUp, PageDown.", + "default": null, "type": [ "string", "null" ] }, "ref": { - "default": null, "description": "Element ref from `browser::snapshot` (`e3`) or `browser::picked` (`p1`). Refs die on navigation; re-snapshot after.", + "default": null, "type": [ "string", "null" @@ -55,41 +61,41 @@ "type": "string" }, "text": { - "default": null, "description": "Text to insert (`type`).", + "default": null, "type": [ "string", "null" ] }, "x": { - "default": null, "description": "Viewport x, when acting by coordinates instead of ref.", - "format": "double", + "default": null, "type": [ "number", "null" - ] + ], + "format": "double" }, "y": { - "default": null, "description": "Viewport y, when acting by coordinates instead of ref.", - "format": "double", + "default": null, "type": [ "number", "null" - ] + ], + "format": "double" } - }, - "required": [ - "action", - "session_id" - ], - "title": "ActInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ActOutput", + "type": "object", + "required": [ + "detail", + "ok" + ], "properties": { "detail": { "description": "What was done, for the transcript.", @@ -98,12 +104,6 @@ "ok": { "type": "boolean" } - }, - "required": [ - "detail", - "ok" - ], - "title": "ActOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.console.read.json b/browser/tests/golden/schemas/browser.console.read.json index 899f9d4f0..1df35ec53 100644 --- a/browser/tests/golden/schemas/browser.console.read.json +++ b/browser/tests/golden/schemas/browser.console.read.json @@ -1,30 +1,35 @@ { - "description": "Read the session's captured console: console.* calls, uncaught exceptions, and browser-level log entries. Filter with pattern/level and page with since_seq instead of dumping everything.", "function_id": "browser::console::read", + "description": "Read the session's captured console: console.* calls, uncaught exceptions, and browser-level log entries. Filter with pattern/level and page with since_seq instead of dumping everything.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ConsoleReadInput", + "type": "object", + "required": [ + "session_id" + ], "properties": { "level": { - "default": null, "description": "Only entries at this level: `log`, `info`, `warning`, `error`, `debug`, `exception`. `error` also matches `exception`.", + "default": null, "type": [ "string", "null" ] }, "limit": { - "default": null, "description": "Maximum entries returned, newest kept (default 100).", - "format": "uint64", - "minimum": 0.0, + "default": null, "type": [ "integer", "null" - ] + ], + "format": "uint64", + "minimum": 0.0 }, "pattern": { - "default": null, "description": "Regex applied to entry text. Use it: dumping an unfiltered console wastes the caller's context.", + "default": null, "type": [ "string", "null" @@ -34,27 +39,56 @@ "type": "string" }, "since_seq": { - "default": null, "description": "Only entries with `seq` greater than this; resume from the cursor returned as `last_seq`.", - "format": "uint64", - "minimum": 0.0, + "default": null, "type": [ "integer", "null" - ] + ], + "format": "uint64", + "minimum": 0.0 } - }, - "required": [ - "session_id" - ], - "title": "ConsoleReadInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ConsoleReadOutput", + "type": "object", + "required": [ + "dropped", + "entries", + "last_seq" + ], + "properties": { + "dropped": { + "description": "Entries evicted from the ring buffer since session start.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/ConsoleEntry" + } + }, + "last_seq": { + "description": "Cursor for the next `since_seq`.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, "definitions": { "ConsoleEntry": { "description": "One captured console/log/exception entry.", + "type": "object", + "required": [ + "level", + "seq", + "text", + "timestamp" + ], "properties": { "level": { "description": "`log`, `info`, `warning`, `error`, `debug`, or `exception`.", @@ -62,9 +96,9 @@ }, "seq": { "description": "Monotonic per-session cursor; pass back as `since_seq`.", + "type": "integer", "format": "uint64", - "minimum": 0.0, - "type": "integer" + "minimum": 0.0 }, "source": { "description": "`url:line` of the emitting frame, when known.", @@ -77,45 +111,11 @@ "type": "string" }, "timestamp": { - "format": "int64", - "type": "integer" + "type": "integer", + "format": "int64" } - }, - "required": [ - "level", - "seq", - "text", - "timestamp" - ], - "type": "object" - } - }, - "properties": { - "dropped": { - "description": "Entries evicted from the ring buffer since session start.", - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "entries": { - "items": { - "$ref": "#/definitions/ConsoleEntry" - }, - "type": "array" - }, - "last_seq": { - "description": "Cursor for the next `since_seq`.", - "format": "uint64", - "minimum": 0.0, - "type": "integer" + } } - }, - "required": [ - "dropped", - "entries", - "last_seq" - ], - "title": "ConsoleReadOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.crawl.json b/browser/tests/golden/schemas/browser.crawl.json new file mode 100644 index 000000000..7e50287d2 --- /dev/null +++ b/browser/tests/golden/schemas/browser.crawl.json @@ -0,0 +1,153 @@ +{ + "function_id": "browser::crawl", + "description": "BFS-crawl from start_urls (follow same-domain links), extract per page, stream items.", + "request_schema": { + "type": "object", + "properties": { + "start_urls": { + "type": "array", + "items": { + "type": "string" + } + }, + "url": { + "type": "string", + "description": "single start URL (alternative to start_urls)" + }, + "fetcher": { + "type": "string", + "enum": [ + "http", + "stealthy", + "dynamic" + ] + }, + "selectors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "css": { + "type": "string" + }, + "xpath": { + "type": "string" + }, + "regex": { + "type": "string" + }, + "attr": { + "type": "string", + "description": "extract this attribute instead of text" + }, + "html": { + "type": "boolean", + "description": "extract inner HTML instead of text" + }, + "all": { + "type": "boolean", + "description": "return every match as a list" + } + }, + "required": [ + "name" + ] + } + }, + "allowed_domains": { + "type": "array", + "items": { + "type": "string" + }, + "description": "only follow links on these hosts" + }, + "same_domain": { + "type": "boolean", + "description": "follow only same-host links (default true)" + }, + "max_pages": { + "type": "integer" + }, + "max_depth": { + "type": "integer" + }, + "concurrency": { + "type": "integer" + }, + "download_delay": { + "type": "number", + "description": "seconds to wait between crawl rounds" + }, + "format": { + "type": "string", + "enum": [ + "markdown", + "text" + ], + "description": "render page body to this format" + }, + "main_content_only": { + "type": "boolean", + "description": "strip nav/scripts/hidden before rendering" + }, + "css_selector": { + "type": "string", + "description": "scope the render to this CSS subtree (e.g. a page's content div)" + }, + "include_html": { + "type": "boolean" + }, + "impersonate": { + "type": "string" + }, + "stream_name": { + "type": "string", + "description": "stream to emit items on (default browser::crawl)" + } + } + }, + "response_schema": { + "type": "object", + "properties": { + "stats": { + "type": "object", + "properties": { + "crawled": { + "type": "integer" + }, + "items": { + "type": "integer" + }, + "errors": { + "type": "integer" + }, + "stopped": { + "type": "string" + } + } + }, + "items": { + "type": "array", + "items": { + "type": "object" + }, + "description": "a small sample of streamed items" + }, + "stream": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "group_id": { + "type": "string" + } + }, + "description": "read the full item stream via stream::on with this name + group_id" + } + } + } +} diff --git a/browser/tests/golden/schemas/browser.css.json b/browser/tests/golden/schemas/browser.css.json new file mode 100644 index 000000000..f08400418 --- /dev/null +++ b/browser/tests/golden/schemas/browser.css.json @@ -0,0 +1,59 @@ +{ + "function_id": "browser::css", + "description": "One CSS query over HTML; first-or-all; `attr` pulls an attribute else text.", + "request_schema": { + "type": "object", + "properties": { + "html": { + "type": "string" + }, + "query": { + "type": "string" + }, + "first": { + "type": "boolean" + }, + "attr": { + "type": "string" + }, + "identifier": { + "type": "string", + "description": "stable key for the saved element" + }, + "adaptive": { + "type": "boolean", + "description": "relocate elements after a site change via saved identities" + }, + "auto_save": { + "type": "boolean", + "description": "save matched identities (defaults on when adaptive)" + }, + "adaptive_domain": { + "type": "string", + "description": "page URL/domain that keys saved identities" + } + }, + "required": [ + "html", + "query" + ] + }, + "response_schema": { + "type": "object", + "properties": { + "result": { + "type": [ + "array", + "string", + "null" + ], + "items": { + "type": [ + "string", + "null" + ] + } + } + } + } +} diff --git a/browser/tests/golden/schemas/browser.describe.json b/browser/tests/golden/schemas/browser.describe.json new file mode 100644 index 000000000..3ff5b76f9 --- /dev/null +++ b/browser/tests/golden/schemas/browser.describe.json @@ -0,0 +1,81 @@ +{ + "function_id": "browser::describe", + "description": "Describe the first css/xpath match: attrs, generated selectors, class list, DOM context.", + "request_schema": { + "type": "object", + "properties": { + "html": { + "type": "string" + }, + "query": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "css", + "xpath" + ] + } + }, + "required": [ + "html", + "query" + ] + }, + "response_schema": { + "type": "object", + "properties": { + "found": { + "type": "boolean" + }, + "element": { + "type": "object", + "properties": { + "tag": { + "type": "string" + }, + "text": { + "type": "string" + }, + "html": { + "type": "string" + }, + "attrs": { + "type": "object" + }, + "css": { + "type": "string" + }, + "xpath": { + "type": "string" + }, + "full_css": { + "type": "string" + }, + "full_xpath": { + "type": "string" + }, + "classes": { + "type": "array", + "items": { + "type": "string" + } + }, + "parent_tag": { + "type": [ + "string", + "null" + ] + }, + "children": { + "type": "integer" + }, + "siblings": { + "type": "integer" + } + } + } + } + } +} diff --git a/browser/tests/golden/schemas/browser.doctor.json b/browser/tests/golden/schemas/browser.doctor.json index d1dced1d1..b54615ff2 100644 --- a/browser/tests/golden/schemas/browser.doctor.json +++ b/browser/tests/golden/schemas/browser.doctor.json @@ -1,6 +1,6 @@ { - "description": "Read-only environment diagnostics: which Chromium the worker would launch, its version, session capacity, and any degraded capability with how to enable it. Never starts a browser.", "function_id": "browser::doctor", + "description": "Read-only environment diagnostics: which Chromium the worker would launch, its version, session capacity, and any degraded capability with how to enable it. Never starts a browser.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "DoctorInput", @@ -8,35 +8,29 @@ }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "DoctorIssue": { - "description": "One degraded capability plus the way to enable it.", - "properties": { - "enable_how": { - "type": "string" - }, - "what": { - "type": "string" - } - }, - "required": [ - "enable_how", - "what" - ], - "type": "object" - } - }, + "title": "DoctorOutput", + "type": "object", + "required": [ + "active_sessions", + "allowed_schemes", + "attach_enabled", + "headless_default", + "issues", + "max_sessions", + "ok", + "recording_available" + ], "properties": { "active_sessions": { + "type": "integer", "format": "uint64", - "minimum": 0.0, - "type": "integer" + "minimum": 0.0 }, "allowed_schemes": { + "type": "array", "items": { "type": "string" - }, - "type": "array" + } }, "attach_enabled": { "description": "Whether attach mode is enabled (allow_attach).", @@ -58,15 +52,15 @@ "type": "boolean" }, "issues": { + "type": "array", "items": { "$ref": "#/definitions/DoctorIssue" - }, - "type": "array" + } }, "max_sessions": { + "type": "integer", "format": "uint64", - "minimum": 0.0, - "type": "integer" + "minimum": 0.0 }, "ok": { "description": "True when sessions can start right now.", @@ -77,17 +71,23 @@ "type": "boolean" } }, - "required": [ - "active_sessions", - "allowed_schemes", - "attach_enabled", - "headless_default", - "issues", - "max_sessions", - "ok", - "recording_available" - ], - "title": "DoctorOutput", - "type": "object" + "definitions": { + "DoctorIssue": { + "description": "One degraded capability plus the way to enable it.", + "type": "object", + "required": [ + "enable_how", + "what" + ], + "properties": { + "enable_how": { + "type": "string" + }, + "what": { + "type": "string" + } + } + } + } } } diff --git a/browser/tests/golden/schemas/browser.dom.read.json b/browser/tests/golden/schemas/browser.dom.read.json index 02b2b593a..816afa910 100644 --- a/browser/tests/golden/schemas/browser.dom.read.json +++ b/browser/tests/golden/schemas/browser.dom.read.json @@ -1,22 +1,27 @@ { - "description": "Read the DOM as a tree of tags with id/class and refs. Structure-oriented complement to browser::snapshot; read deep subtrees by passing a ref.", "function_id": "browser::dom::read", + "description": "Read the DOM as a tree of tags with id/class and refs. Structure-oriented complement to browser::snapshot; read deep subtrees by passing a ref.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DomReadInput", + "type": "object", + "required": [ + "session_id" + ], "properties": { "depth": { - "default": null, "description": "Levels of children to include (default 3).", - "format": "uint32", - "minimum": 0.0, + "default": null, "type": [ "integer", "null" - ] + ], + "format": "uint32", + "minimum": 0.0 }, "ref": { - "default": null, "description": "Subtree root from an earlier ref (`e3`/`p1`) or dom node. Omit for the document root.", + "default": null, "type": [ "string", "null" @@ -25,30 +30,46 @@ "session_id": { "type": "string" } - }, - "required": [ - "session_id" - ], - "title": "DomReadInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DomReadOutput", + "type": "object", + "required": [ + "root", + "truncated" + ], + "properties": { + "root": { + "$ref": "#/definitions/DomNode" + }, + "truncated": { + "description": "True when the node cap cut the tree short; read a subtree via `ref`.", + "type": "boolean" + } + }, "definitions": { "DomNode": { "description": "One DOM node in the outline. `ref` resolves in `browser::act`, `browser::styles::read`, and `browser::styles::write`.", + "type": "object", + "required": [ + "child_count", + "ref", + "tag" + ], "properties": { "child_count": { "description": "Total children in the document, which may exceed `children` returned at this depth.", + "type": "integer", "format": "uint32", - "minimum": 0.0, - "type": "integer" + "minimum": 0.0 }, "children": { + "type": "array", "items": { "$ref": "#/definitions/DomNode" - }, - "type": "array" + } }, "classes": { "type": [ @@ -76,29 +97,8 @@ "null" ] } - }, - "required": [ - "child_count", - "ref", - "tag" - ], - "type": "object" + } } - }, - "properties": { - "root": { - "$ref": "#/definitions/DomNode" - }, - "truncated": { - "description": "True when the node cap cut the tree short; read a subtree via `ref`.", - "type": "boolean" - } - }, - "required": [ - "root", - "truncated" - ], - "title": "DomReadOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.dynamic-fetch.json b/browser/tests/golden/schemas/browser.dynamic-fetch.json new file mode 100644 index 000000000..54b6a975d --- /dev/null +++ b/browser/tests/golden/schemas/browser.dynamic-fetch.json @@ -0,0 +1,215 @@ +{ + "function_id": "browser::dynamic-fetch", + "description": "Playwright/Chromium fetch: JS render, waits, XHR capture, CDP; extraction + bulk.", + "request_schema": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "urls": { + "type": "array", + "items": { + "type": "string" + } + }, + "headless": { + "type": "boolean" + }, + "network_idle": { + "type": "boolean" + }, + "load_dom": { + "type": "boolean" + }, + "timeout": { + "type": "number", + "description": "milliseconds (browser fetcher)" + }, + "wait": { + "type": "number", + "description": "extra ms to wait after load" + }, + "wait_selector": { + "type": "string" + }, + "wait_selector_state": { + "type": "string", + "enum": [ + "attached", + "detached", + "visible", + "hidden" + ] + }, + "disable_resources": { + "type": "boolean" + }, + "block_ads": { + "type": "boolean" + }, + "blocked_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "proxy": { + "type": "string" + }, + "useragent": { + "type": "string" + }, + "cookies": { + "type": "object" + }, + "extra_headers": { + "type": "object" + }, + "google_search": { + "type": "boolean" + }, + "capture_xhr": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "timezone_id": { + "type": "string" + }, + "dns_over_https": { + "type": "boolean" + }, + "extra_flags": { + "type": "array", + "items": { + "type": "string" + } + }, + "max_pages": { + "type": "integer" + }, + "retries": { + "type": "integer" + }, + "retry_delay": { + "type": "number" + }, + "real_chrome": { + "type": "boolean" + }, + "cdp_url": { + "type": "string" + }, + "selectors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "css": { + "type": "string" + }, + "xpath": { + "type": "string" + }, + "regex": { + "type": "string" + }, + "attr": { + "type": "string", + "description": "extract this attribute instead of text" + }, + "html": { + "type": "boolean", + "description": "extract inner HTML instead of text" + }, + "all": { + "type": "boolean", + "description": "return every match as a list" + } + }, + "required": [ + "name" + ] + } + }, + "include_html": { + "type": "boolean" + }, + "format": { + "type": "string", + "enum": [ + "markdown", + "text" + ], + "description": "render page body to this format" + }, + "main_content_only": { + "type": "boolean", + "description": "strip nav/scripts/hidden before rendering" + }, + "css_selector": { + "type": "string", + "description": "scope the render to this CSS subtree (e.g. a page's content div)" + } + } + }, + "response_schema": { + "type": "object", + "properties": { + "status": { + "type": [ + "integer", + "null" + ] + }, + "url": { + "type": "string" + }, + "headers": { + "type": "object" + }, + "cookies": { + "type": "object" + }, + "encoding": { + "type": [ + "string", + "null" + ] + }, + "extracted": { + "type": "object" + }, + "html": { + "type": "string" + }, + "content": { + "type": "string", + "description": "markdown/text render when `format` requested" + }, + "format": { + "type": "string" + }, + "captured_xhr": { + "type": "array", + "items": { + "type": "object" + } + }, + "results": { + "type": "array", + "items": { + "type": "object" + } + }, + "error": { + "type": "string" + } + } + } +} diff --git a/browser/tests/golden/schemas/browser.evaluate.json b/browser/tests/golden/schemas/browser.evaluate.json index 6856f1292..3d9ded16f 100644 --- a/browser/tests/golden/schemas/browser.evaluate.json +++ b/browser/tests/golden/schemas/browser.evaluate.json @@ -1,8 +1,14 @@ { - "description": "Evaluate a JavaScript expression in the page and return its completion value. Use for reads the snapshot can't express; prefer browser::act for interactions.", "function_id": "browser::evaluate", + "description": "Evaluate a JavaScript expression in the page and return its completion value. Use for reads the snapshot can't express; prefer browser::act for interactions.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "EvaluateInput", + "type": "object", + "required": [ + "expression", + "session_id" + ], "properties": { "expression": { "description": "JavaScript expression evaluated in the page. The completion value is returned by value; wrap statements in an IIFE.", @@ -12,25 +18,24 @@ "type": "string" }, "timeout_ms": { - "default": null, "description": "Upper bound on evaluation; clamped to `max_timeout_ms`.", - "format": "uint64", - "minimum": 0.0, + "default": null, "type": [ "integer", "null" - ] + ], + "format": "uint64", + "minimum": 0.0 } - }, - "required": [ - "expression", - "session_id" - ], - "title": "EvaluateInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "EvaluateOutput", + "type": "object", + "required": [ + "ok" + ], "properties": { "error": { "description": "Exception text when not `ok`.", @@ -45,11 +50,6 @@ "value": { "description": "JSON completion value when `ok`." } - }, - "required": [ - "ok" - ], - "title": "EvaluateOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.execute.json b/browser/tests/golden/schemas/browser.execute.json index 483750fb8..b1c90c1c6 100644 --- a/browser/tests/golden/schemas/browser.execute.json +++ b/browser/tests/golden/schemas/browser.execute.json @@ -1,8 +1,14 @@ { - "description": "Run a multi-step async JavaScript script in the page: top-level await and return work, with log(...), sleep(ms), waitFor(selector), and a state object that persists across execute calls for the session. One call replaces a chain of act/evaluate round-trips; returns { result, logs, state }.", "function_id": "browser::execute", + "description": "Run a multi-step async JavaScript script in the page: top-level await and return work, with log(...), sleep(ms), waitFor(selector), and a state object that persists across execute calls for the session. One call replaces a chain of act/evaluate round-trips; returns { result, logs, state }.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteInput", + "type": "object", + "required": [ + "code", + "session_id" + ], "properties": { "code": { "description": "Async JavaScript body run in the page. Top-level `await` and `return` work. In scope: `state` (JSON object persisted across execute calls for the session), `log(...)` (collected into the response), `sleep(ms)`, and `waitFor(selector, { timeout })`. Return plain JSON.", @@ -12,25 +18,26 @@ "type": "string" }, "timeout_ms": { - "default": null, "description": "Upper bound on the run; clamped to `max_timeout_ms`.", - "format": "uint64", - "minimum": 0.0, + "default": null, "type": [ "integer", "null" - ] + ], + "format": "uint64", + "minimum": 0.0 } - }, - "required": [ - "code", - "session_id" - ], - "title": "ExecuteInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteOutput", + "type": "object", + "required": [ + "logs", + "ok", + "state" + ], "properties": { "error": { "description": "Exception text when not `ok`. A \"context destroyed\" error usually means the script navigated; split the script at the navigation.", @@ -41,10 +48,10 @@ }, "logs": { "description": "`log(...)` output collected during the run, in order.", + "type": "array", "items": { "type": "string" - }, - "type": "array" + } }, "ok": { "type": "boolean" @@ -55,13 +62,6 @@ "state": { "description": "Session state after the run; the next execute call sees this as `state`." } - }, - "required": [ - "logs", - "ok", - "state" - ], - "title": "ExecuteOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.extract.json b/browser/tests/golden/schemas/browser.extract.json new file mode 100644 index 000000000..946972b23 --- /dev/null +++ b/browser/tests/golden/schemas/browser.extract.json @@ -0,0 +1,71 @@ +{ + "function_id": "browser::extract", + "description": "Parse HTML with a selector list (css/xpath/regex, text/attr/html, all-or-first).", + "request_schema": { + "type": "object", + "properties": { + "html": { + "type": "string" + }, + "selectors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "css": { + "type": "string" + }, + "xpath": { + "type": "string" + }, + "regex": { + "type": "string" + }, + "attr": { + "type": "string", + "description": "extract this attribute instead of text" + }, + "html": { + "type": "boolean", + "description": "extract inner HTML instead of text" + }, + "all": { + "type": "boolean", + "description": "return every match as a list" + } + }, + "required": [ + "name" + ] + } + }, + "adaptive": { + "type": "boolean", + "description": "relocate elements after a site change via saved identities" + }, + "auto_save": { + "type": "boolean", + "description": "save matched identities (defaults on when adaptive)" + }, + "adaptive_domain": { + "type": "string", + "description": "page URL/domain that keys saved identities" + } + }, + "required": [ + "html", + "selectors" + ] + }, + "response_schema": { + "type": "object", + "properties": { + "extracted": { + "type": "object" + } + } + } +} diff --git a/browser/tests/golden/schemas/browser.fetch.json b/browser/tests/golden/schemas/browser.fetch.json new file mode 100644 index 000000000..a963922a0 --- /dev/null +++ b/browser/tests/golden/schemas/browser.fetch.json @@ -0,0 +1,193 @@ +{ + "function_id": "browser::fetch", + "description": "Fast HTTP fetch, TLS impersonation: get/post/put/delete, inline extraction, bulk `urls`.", + "request_schema": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "urls": { + "type": "array", + "items": { + "type": "string" + } + }, + "method": { + "type": "string", + "enum": [ + "get", + "post", + "put", + "delete" + ] + }, + "headers": { + "type": "object" + }, + "params": { + "type": "object" + }, + "data": { + "type": "object" + }, + "json": { + "type": "object" + }, + "cookies": { + "type": "object" + }, + "proxy": { + "type": "string" + }, + "proxies": { + "type": "object", + "description": "per-scheme proxies, e.g. {\"https\": \"http://...\"}" + }, + "proxy_auth": { + "type": "array", + "items": { + "type": "string" + }, + "description": "[user, password]" + }, + "impersonate": { + "type": "string", + "description": "TLS/UA fingerprint, e.g. 'chrome'" + }, + "timeout": { + "type": "number", + "description": "seconds (HTTP fetcher)" + }, + "follow_redirects": { + "type": "boolean" + }, + "max_redirects": { + "type": "integer" + }, + "stealthy_headers": { + "type": "boolean" + }, + "http3": { + "type": "boolean" + }, + "verify": { + "type": "boolean" + }, + "retries": { + "type": "integer" + }, + "retry_delay": { + "type": "number" + }, + "selectors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "css": { + "type": "string" + }, + "xpath": { + "type": "string" + }, + "regex": { + "type": "string" + }, + "attr": { + "type": "string", + "description": "extract this attribute instead of text" + }, + "html": { + "type": "boolean", + "description": "extract inner HTML instead of text" + }, + "all": { + "type": "boolean", + "description": "return every match as a list" + } + }, + "required": [ + "name" + ] + } + }, + "include_html": { + "type": "boolean" + }, + "format": { + "type": "string", + "enum": [ + "markdown", + "text" + ], + "description": "render page body to this format" + }, + "main_content_only": { + "type": "boolean", + "description": "strip nav/scripts/hidden before rendering" + }, + "css_selector": { + "type": "string", + "description": "scope the render to this CSS subtree (e.g. a page's content div)" + } + } + }, + "response_schema": { + "type": "object", + "properties": { + "status": { + "type": [ + "integer", + "null" + ] + }, + "url": { + "type": "string" + }, + "headers": { + "type": "object" + }, + "cookies": { + "type": "object" + }, + "encoding": { + "type": [ + "string", + "null" + ] + }, + "extracted": { + "type": "object" + }, + "html": { + "type": "string" + }, + "content": { + "type": "string", + "description": "markdown/text render when `format` requested" + }, + "format": { + "type": "string" + }, + "captured_xhr": { + "type": "array", + "items": { + "type": "object" + } + }, + "results": { + "type": "array", + "items": { + "type": "object" + } + }, + "error": { + "type": "string" + } + } + } +} diff --git a/browser/tests/golden/schemas/browser.find-by-regex.json b/browser/tests/golden/schemas/browser.find-by-regex.json new file mode 100644 index 000000000..28b0a83c0 --- /dev/null +++ b/browser/tests/golden/schemas/browser.find-by-regex.json @@ -0,0 +1,65 @@ +{ + "function_id": "browser::find-by-regex", + "description": "Find elements whose visible text matches a regex pattern.", + "request_schema": { + "type": "object", + "properties": { + "html": { + "type": "string" + }, + "pattern": { + "type": "string" + }, + "case_sensitive": { + "type": "boolean" + }, + "clean_match": { + "type": "boolean" + }, + "first": { + "type": "boolean" + }, + "limit": { + "type": "integer" + } + }, + "required": [ + "html", + "pattern" + ] + }, + "response_schema": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tag": { + "type": "string" + }, + "text": { + "type": "string" + }, + "html": { + "type": "string" + }, + "attrs": { + "type": "object" + }, + "css": { + "type": "string" + }, + "xpath": { + "type": "string" + } + } + } + } + } + } +} diff --git a/browser/tests/golden/schemas/browser.find-by-text.json b/browser/tests/golden/schemas/browser.find-by-text.json new file mode 100644 index 000000000..545b0a684 --- /dev/null +++ b/browser/tests/golden/schemas/browser.find-by-text.json @@ -0,0 +1,70 @@ +{ + "function_id": "browser::find-by-text", + "description": "Find elements whose visible text matches a string (exact or `partial`).", + "request_schema": { + "type": "object", + "properties": { + "html": { + "type": "string" + }, + "text": { + "type": "string" + }, + "partial": { + "type": "boolean", + "description": "match elements that contain the text" + }, + "case_sensitive": { + "type": "boolean" + }, + "clean_match": { + "type": "boolean", + "description": "ignore surrounding/collapsing whitespace" + }, + "first": { + "type": "boolean" + }, + "limit": { + "type": "integer" + } + }, + "required": [ + "html", + "text" + ] + }, + "response_schema": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tag": { + "type": "string" + }, + "text": { + "type": "string" + }, + "html": { + "type": "string" + }, + "attrs": { + "type": "object" + }, + "css": { + "type": "string" + }, + "xpath": { + "type": "string" + } + } + } + } + } + } +} diff --git a/browser/tests/golden/schemas/browser.find-similar.json b/browser/tests/golden/schemas/browser.find-similar.json new file mode 100644 index 000000000..85f239837 --- /dev/null +++ b/browser/tests/golden/schemas/browser.find-similar.json @@ -0,0 +1,75 @@ +{ + "function_id": "browser::find-similar", + "description": "Structural auto-match: given one example element, return it plus similar elements.", + "request_schema": { + "type": "object", + "properties": { + "html": { + "type": "string" + }, + "anchor": { + "type": "string", + "description": "CSS selector to one example element" + }, + "similarity_threshold": { + "type": "number" + }, + "match_text": { + "type": "boolean" + }, + "selectors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "css": { + "type": "string" + }, + "xpath": { + "type": "string" + }, + "regex": { + "type": "string" + }, + "attr": { + "type": "string", + "description": "extract this attribute instead of text" + }, + "html": { + "type": "boolean", + "description": "extract inner HTML instead of text" + }, + "all": { + "type": "boolean", + "description": "return every match as a list" + } + }, + "required": [ + "name" + ] + } + } + }, + "required": [ + "html", + "anchor" + ] + }, + "response_schema": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "type": "object" + } + } + } + } +} diff --git a/browser/tests/golden/schemas/browser.find.json b/browser/tests/golden/schemas/browser.find.json new file mode 100644 index 000000000..20dcaa9df --- /dev/null +++ b/browser/tests/golden/schemas/browser.find.json @@ -0,0 +1,73 @@ +{ + "function_id": "browser::find", + "description": "Find elements by tag/attribute filters (+ optional text regex); BeautifulSoup-style.", + "request_schema": { + "type": "object", + "properties": { + "html": { + "type": "string" + }, + "tag": { + "type": [ + "string", + "array" + ], + "items": { + "type": "string" + }, + "description": "tag name or list of tag names" + }, + "attrs": { + "type": "object", + "description": "attribute filters, e.g. {\"class\": \"card\"}" + }, + "text_regex": { + "type": "string", + "description": "keep only elements whose text matches this regex" + }, + "first": { + "type": "boolean" + }, + "limit": { + "type": "integer" + } + }, + "required": [ + "html" + ] + }, + "response_schema": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tag": { + "type": "string" + }, + "text": { + "type": "string" + }, + "html": { + "type": "string" + }, + "attrs": { + "type": "object" + }, + "css": { + "type": "string" + }, + "xpath": { + "type": "string" + } + } + } + } + } + } +} diff --git a/browser/tests/golden/schemas/browser.frame.json b/browser/tests/golden/schemas/browser.frame.json index 160670159..b6dbce2b9 100644 --- a/browser/tests/golden/schemas/browser.frame.json +++ b/browser/tests/golden/schemas/browser.frame.json @@ -1,31 +1,40 @@ { - "description": "Internal: newest screencast frame, or nothing when since_frame is still current. No capture round-trip; poll fast. Not an agent function.", "function_id": "browser::frame", + "description": "Internal: newest screencast frame, or nothing when since_frame is still current. No capture round-trip; poll fast. Not an agent function.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "FrameInput", + "type": "object", + "required": [ + "session_id" + ], "properties": { "session_id": { "type": "string" }, "since_frame": { - "default": null, "description": "Frame cursor from the previous read; when the newest frame still has this seq the response omits `frame` (nothing changed, nothing to redraw).", - "format": "uint64", - "minimum": 0.0, + "default": null, "type": [ "integer", "null" - ] + ], + "format": "uint64", + "minimum": 0.0 } - }, - "required": [ - "session_id" - ], - "title": "FrameInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "FrameOutput", + "type": "object", + "required": [ + "active", + "frame_seq", + "height", + "timestamp", + "width" + ], "properties": { "active": { "description": "False when no screencast is running (call screencast::start first).", @@ -39,35 +48,26 @@ ] }, "frame_seq": { + "type": "integer", "format": "uint64", - "minimum": 0.0, - "type": "integer" + "minimum": 0.0 }, "height": { "description": "Page-viewport height the frame maps to.", + "type": "integer", "format": "uint32", - "minimum": 0.0, - "type": "integer" + "minimum": 0.0 }, "timestamp": { - "format": "int64", - "type": "integer" + "type": "integer", + "format": "int64" }, "width": { "description": "Page-viewport width the frame maps to (input coordinate space).", + "type": "integer", "format": "uint32", - "minimum": 0.0, - "type": "integer" + "minimum": 0.0 } - }, - "required": [ - "active", - "frame_seq", - "height", - "timestamp", - "width" - ], - "title": "FrameOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.handoff.confirm.json b/browser/tests/golden/schemas/browser.handoff.confirm.json index 137e6e0d8..66b9c2bce 100644 --- a/browser/tests/golden/schemas/browser.handoff.confirm.json +++ b/browser/tests/golden/schemas/browser.handoff.confirm.json @@ -1,12 +1,14 @@ { - "description": "Resolve a paused browser::handoff by handoff_id, or the one pending handoff for a session_id. The console calls this when the human confirms outside the page.", "function_id": "browser::handoff::confirm", + "description": "Resolve a paused browser::handoff by handoff_id, or the one pending handoff for a session_id. The console calls this when the human confirms outside the page.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HandoffConfirmInput", + "type": "object", "properties": { "handoff_id": { - "default": null, "description": "Confirm a specific handoff by id. Omit to confirm the one pending handoff for `session_id`.", + "default": null, "type": [ "string", "null" @@ -19,12 +21,15 @@ "null" ] } - }, - "title": "HandoffConfirmInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HandoffConfirmOutput", + "type": "object", + "required": [ + "ok" + ], "properties": { "handoff_id": { "type": [ @@ -36,11 +41,6 @@ "description": "True when a pending handoff matched and was resolved.", "type": "boolean" } - }, - "required": [ - "ok" - ], - "title": "HandoffConfirmOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.handoff.json b/browser/tests/golden/schemas/browser.handoff.json index 2ab0a9ba1..eb144e85a 100644 --- a/browser/tests/golden/schemas/browser.handoff.json +++ b/browser/tests/golden/schemas/browser.handoff.json @@ -1,8 +1,14 @@ { - "description": "Pause a session for a step only a human can do (CAPTCHA, 2FA, payment): show an in-page continue banner and block until the human clicks it, a browser::handoff::confirm call resolves it, or the timeout elapses. Human acknowledgment is not proof — verify the expected page state after it returns.", "function_id": "browser::handoff", + "description": "Pause a session for a step only a human can do (CAPTCHA, 2FA, payment): show an in-page continue banner and block until the human clicks it, a browser::handoff::confirm call resolves it, or the timeout elapses. Human acknowledgment is not proof — verify the expected page state after it returns.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HandoffInput", + "type": "object", + "required": [ + "instructions", + "session_id" + ], "properties": { "instructions": { "description": "What the human must do before the call continues. Shown in the in-page banner and the handoff-requested event.", @@ -12,25 +18,27 @@ "type": "string" }, "timeout_ms": { - "default": null, "description": "Give up after this long and return with `via: \"timeout\"`. Defaults to the config default; clamped to `max_timeout_ms`. Set generously; a human is slow.", - "format": "uint64", - "minimum": 0.0, + "default": null, "type": [ "integer", "null" - ] + ], + "format": "uint64", + "minimum": 0.0 } - }, - "required": [ - "instructions", - "session_id" - ], - "title": "HandoffInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HandoffOutput", + "type": "object", + "required": [ + "confirmed", + "handoff_id", + "url", + "via" + ], "properties": { "confirmed": { "description": "True when a human confirmed; false when the wait timed out.", @@ -47,14 +55,6 @@ "description": "How the confirmation arrived: `in_page`, `confirm_call`, or `timeout`.", "type": "string" } - }, - "required": [ - "confirmed", - "handoff_id", - "url", - "via" - ], - "title": "HandoffOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.history.json b/browser/tests/golden/schemas/browser.history.json index 08d6b6a1c..509189a4a 100644 --- a/browser/tests/golden/schemas/browser.history.json +++ b/browser/tests/golden/schemas/browser.history.json @@ -1,8 +1,14 @@ { - "description": "Go back, go forward, or reload the session's page. Back/forward at the history edge is a no-op with moved=false.", "function_id": "browser::history", + "description": "Go back, go forward, or reload the session's page. Back/forward at the history edge is a no-op with moved=false.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HistoryInput", + "type": "object", + "required": [ + "action", + "session_id" + ], "properties": { "action": { "description": "`back`, `forward`, or `reload`.", @@ -11,16 +17,17 @@ "session_id": { "type": "string" } - }, - "required": [ - "action", - "session_id" - ], - "title": "HistoryInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HistoryOutput", + "type": "object", + "required": [ + "moved", + "ok", + "url" + ], "properties": { "moved": { "description": "False when back/forward had no entry to move to.", @@ -33,13 +40,6 @@ "description": "URL after the action. `back`/`forward` at the history edge is a no-op with ok=true.", "type": "string" } - }, - "required": [ - "moved", - "ok", - "url" - ], - "title": "HistoryOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.navigate.json b/browser/tests/golden/schemas/browser.navigate.json index 519ca5151..df5be9802 100644 --- a/browser/tests/golden/schemas/browser.navigate.json +++ b/browser/tests/golden/schemas/browser.navigate.json @@ -1,36 +1,43 @@ { - "description": "Navigate a session to a URL and wait for the page to load. Element refs from earlier snapshots are invalidated by navigation.", "function_id": "browser::navigate", + "description": "Navigate a session to a URL and wait for the page to load. Element refs from earlier snapshots are invalidated by navigation.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NavigateInput", + "type": "object", + "required": [ + "session_id", + "url" + ], "properties": { "session_id": { "type": "string" }, "timeout_ms": { - "default": null, "description": "Upper bound on the navigation wait; clamped to `max_timeout_ms`.", - "format": "uint64", - "minimum": 0.0, + "default": null, "type": [ "integer", "null" - ] + ], + "format": "uint64", + "minimum": 0.0 }, "url": { "description": "Absolute URL; scheme must be on the configured allowlist.", "type": "string" } - }, - "required": [ - "session_id", - "url" - ], - "title": "NavigateInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NavigateOutput", + "type": "object", + "required": [ + "ok", + "timed_out", + "url" + ], "properties": { "ok": { "type": "boolean" @@ -49,13 +56,6 @@ "description": "URL after redirects.", "type": "string" } - }, - "required": [ - "ok", - "timed_out", - "url" - ], - "title": "NavigateOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.network.read.json b/browser/tests/golden/schemas/browser.network.read.json index 925ce23c8..de626428e 100644 --- a/browser/tests/golden/schemas/browser.network.read.json +++ b/browser/tests/golden/schemas/browser.network.read.json @@ -1,30 +1,35 @@ { - "description": "Read the session's captured network requests (method, URL, status, failures). failed_only=true is the fast path for 'what broke'.", "function_id": "browser::network::read", + "description": "Read the session's captured network requests (method, URL, status, failures). failed_only=true is the fast path for 'what broke'.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NetworkReadInput", + "type": "object", + "required": [ + "session_id" + ], "properties": { "failed_only": { - "default": null, "description": "Only failed requests (network error or status >= 400).", + "default": null, "type": [ "boolean", "null" ] }, "limit": { - "default": null, "description": "Maximum entries returned, newest kept (default 100).", - "format": "uint64", - "minimum": 0.0, + "default": null, "type": [ "integer", "null" - ] + ], + "format": "uint64", + "minimum": 0.0 }, "pattern": { - "default": null, "description": "Regex applied to the request URL.", + "default": null, "type": [ "string", "null" @@ -34,27 +39,57 @@ "type": "string" }, "since_seq": { - "default": null, "description": "Only entries with `seq` greater than this.", - "format": "uint64", - "minimum": 0.0, + "default": null, "type": [ "integer", "null" - ] + ], + "format": "uint64", + "minimum": 0.0 } - }, - "required": [ - "session_id" - ], - "title": "NetworkReadInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NetworkReadOutput", + "type": "object", + "required": [ + "dropped", + "entries", + "last_seq" + ], + "properties": { + "dropped": { + "description": "Entries evicted from the ring buffer since session start.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/NetworkEntry" + } + }, + "last_seq": { + "description": "Cursor for the next `since_seq`.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, "definitions": { "NetworkEntry": { "description": "One captured network request.", + "type": "object", + "required": [ + "failed", + "method", + "seq", + "timestamp", + "url" + ], "properties": { "error": { "type": [ @@ -76,61 +111,26 @@ }, "seq": { "description": "Monotonic per-session cursor; pass back as `since_seq`.", + "type": "integer", "format": "uint64", - "minimum": 0.0, - "type": "integer" + "minimum": 0.0 }, "status": { - "format": "int64", "type": [ "integer", "null" - ] + ], + "format": "int64" }, "timestamp": { - "format": "int64", - "type": "integer" + "type": "integer", + "format": "int64" }, "url": { "type": "string" } - }, - "required": [ - "failed", - "method", - "seq", - "timestamp", - "url" - ], - "type": "object" + } } - }, - "properties": { - "dropped": { - "description": "Entries evicted from the ring buffer since session start.", - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "entries": { - "items": { - "$ref": "#/definitions/NetworkEntry" - }, - "type": "array" - }, - "last_seq": { - "description": "Cursor for the next `since_seq`.", - "format": "uint64", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "dropped", - "entries", - "last_seq" - ], - "title": "NetworkReadOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.pick.hint.json b/browser/tests/golden/schemas/browser.pick.hint.json index a55ff68f2..845bc1086 100644 --- a/browser/tests/golden/schemas/browser.pick.hint.json +++ b/browser/tests/golden/schemas/browser.pick.hint.json @@ -1,64 +1,41 @@ { - "description": "Internal: element preview at a viewport point (tag, id, classes, bounds) so the console UI can draw a hover highlight in pick mode. Not an agent function.", "function_id": "browser::pick::hint", + "description": "Internal: element preview at a viewport point (tag, id, classes, bounds) so the console UI can draw a hover highlight in pick mode. Not an agent function.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PickHintInput", + "type": "object", + "required": [ + "session_id", + "x", + "y" + ], "properties": { "session_id": { "type": "string" }, "x": { "description": "Viewport x of the cursor.", - "format": "double", - "type": "number" + "type": "number", + "format": "double" }, "y": { "description": "Viewport y of the cursor.", - "format": "double", - "type": "number" + "type": "number", + "format": "double" } - }, - "required": [ - "session_id", - "x", - "y" - ], - "title": "PickHintInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "Bounds": { - "properties": { - "height": { - "format": "double", - "type": "number" - }, - "width": { - "format": "double", - "type": "number" - }, - "x": { - "format": "double", - "type": "number" - }, - "y": { - "format": "double", - "type": "number" - } - }, - "required": [ - "height", - "width", - "x", - "y" - ], - "type": "object" - } - }, + "title": "PickHintOutput", + "type": "object", + "required": [ + "hit" + ], "properties": { "bounds": { + "description": "Viewport-space box to draw the highlight over.", "anyOf": [ { "$ref": "#/definitions/Bounds" @@ -66,8 +43,7 @@ { "type": "null" } - ], - "description": "Viewport-space box to draw the highlight over." + ] }, "classes": { "type": [ @@ -93,10 +69,34 @@ ] } }, - "required": [ - "hit" - ], - "title": "PickHintOutput", - "type": "object" + "definitions": { + "Bounds": { + "type": "object", + "required": [ + "height", + "width", + "x", + "y" + ], + "properties": { + "height": { + "type": "number", + "format": "double" + }, + "width": { + "type": "number", + "format": "double" + }, + "x": { + "type": "number", + "format": "double" + }, + "y": { + "type": "number", + "format": "double" + } + } + } + } } } diff --git a/browser/tests/golden/schemas/browser.pick.resolve.json b/browser/tests/golden/schemas/browser.pick.resolve.json index dca410ea9..0820d489f 100644 --- a/browser/tests/golden/schemas/browser.pick.resolve.json +++ b/browser/tests/golden/schemas/browser.pick.resolve.json @@ -1,43 +1,43 @@ { - "description": "Internal: resolve the element at a clicked viewport point and emit browser::picked. The console calls this on a pick-mode click. Not an agent function.", "function_id": "browser::pick::resolve", + "description": "Internal: resolve the element at a clicked viewport point and emit browser::picked. The console calls this on a pick-mode click. Not an agent function.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PickResolveInput", + "type": "object", + "required": [ + "session_id", + "x", + "y" + ], "properties": { "session_id": { "type": "string" }, "x": { "description": "Viewport x of the click.", - "format": "double", - "type": "number" + "type": "number", + "format": "double" }, "y": { "description": "Viewport y of the click.", - "format": "double", - "type": "number" + "type": "number", + "format": "double" } - }, - "required": [ - "session_id", - "x", - "y" - ], - "title": "PickResolveInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AckOutput", "description": "Neutral acknowledgement returned by the fire-and-forget internal functions (pick start/stop/resolve, screencast start/stop): they emit their result as a trigger event, so the direct return is just `{ ok }`.", + "type": "object", + "required": [ + "ok" + ], "properties": { "ok": { "type": "boolean" } - }, - "required": [ - "ok" - ], - "title": "AckOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.pick.start.json b/browser/tests/golden/schemas/browser.pick.start.json index 254639d65..460988a99 100644 --- a/browser/tests/golden/schemas/browser.pick.start.json +++ b/browser/tests/golden/schemas/browser.pick.start.json @@ -1,31 +1,31 @@ { - "description": "Internal: enter pick mode so the human can select an element in the console UI. Not an agent function.", "function_id": "browser::pick::start", + "description": "Internal: enter pick mode so the human can select an element in the console UI. Not an agent function.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PickStartInput", + "type": "object", + "required": [ + "session_id" + ], "properties": { "session_id": { "type": "string" } - }, - "required": [ - "session_id" - ], - "title": "PickStartInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AckOutput", "description": "Neutral acknowledgement returned by the fire-and-forget internal functions (pick start/stop/resolve, screencast start/stop): they emit their result as a trigger event, so the direct return is just `{ ok }`.", + "type": "object", + "required": [ + "ok" + ], "properties": { "ok": { "type": "boolean" } - }, - "required": [ - "ok" - ], - "title": "AckOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.pick.stop.json b/browser/tests/golden/schemas/browser.pick.stop.json index da66d85c5..719c0cb56 100644 --- a/browser/tests/golden/schemas/browser.pick.stop.json +++ b/browser/tests/golden/schemas/browser.pick.stop.json @@ -1,32 +1,32 @@ { - "description": "Internal: leave DevTools inspect mode without picking. Idempotent. Not an agent function.", "function_id": "browser::pick::stop", + "description": "Internal: leave DevTools inspect mode without picking. Idempotent. Not an agent function.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PickStopInput", + "type": "object", + "required": [ + "session_id" + ], "properties": { "session_id": { "description": "Cancelling pick mode on an unknown session succeeds.", "type": "string" } - }, - "required": [ - "session_id" - ], - "title": "PickStopInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AckOutput", "description": "Neutral acknowledgement returned by the fire-and-forget internal functions (pick start/stop/resolve, screencast start/stop): they emit their result as a trigger event, so the direct return is just `{ ok }`.", + "type": "object", + "required": [ + "ok" + ], "properties": { "ok": { "type": "boolean" } - }, - "required": [ - "ok" - ], - "title": "AckOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.recording.start.json b/browser/tests/golden/schemas/browser.recording.start.json index 1b4fba572..643ee7955 100644 --- a/browser/tests/golden/schemas/browser.recording.start.json +++ b/browser/tests/golden/schemas/browser.recording.start.json @@ -1,12 +1,18 @@ { - "description": "Record a session's live viewport to a video file (webm or mp4) by piping the screencast through ffmpeg. Turns screencast on if needed. Requires ffmpeg on PATH; browser::doctor reports whether it is available.", "function_id": "browser::recording::start", + "description": "Record a session's live viewport to a video file (webm or mp4) by piping the screencast through ffmpeg. Turns screencast on if needed. Requires ffmpeg on PATH; browser::doctor reports whether it is available.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "RecordingStartInput", + "type": "object", + "required": [ + "path", + "session_id" + ], "properties": { "format": { - "default": null, "description": "`webm` (VP9) or `mp4` (H.264). Defaults to webm.", + "default": null, "type": [ "string", "null" @@ -19,16 +25,17 @@ "session_id": { "type": "string" } - }, - "required": [ - "path", - "session_id" - ], - "title": "RecordingStartInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "RecordingStartOutput", + "type": "object", + "required": [ + "format", + "ok", + "path" + ], "properties": { "format": { "type": "string" @@ -39,13 +46,6 @@ "path": { "type": "string" } - }, - "required": [ - "format", - "ok", - "path" - ], - "title": "RecordingStartOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.recording.stop.json b/browser/tests/golden/schemas/browser.recording.stop.json index 9cdf3307b..c625d5936 100644 --- a/browser/tests/golden/schemas/browser.recording.stop.json +++ b/browser/tests/golden/schemas/browser.recording.stop.json @@ -1,32 +1,39 @@ { - "description": "Stop a session's recording, finalize the file, and return its path, duration, and frame count. Idempotent: stopping when nothing is recording returns ok=false.", "function_id": "browser::recording::stop", + "description": "Stop a session's recording, finalize the file, and return its path, duration, and frame count. Idempotent: stopping when nothing is recording returns ok=false.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "RecordingStopInput", + "type": "object", + "required": [ + "session_id" + ], "properties": { "session_id": { "type": "string" } - }, - "required": [ - "session_id" - ], - "title": "RecordingStopInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "RecordingStopOutput", + "type": "object", + "required": [ + "duration_ms", + "frames", + "ok" + ], "properties": { "duration_ms": { "description": "Wall-clock duration captured, milliseconds.", - "format": "int64", - "type": "integer" + "type": "integer", + "format": "int64" }, "frames": { "description": "Frames written to the encoder.", + "type": "integer", "format": "uint64", - "minimum": 0.0, - "type": "integer" + "minimum": 0.0 }, "ok": { "description": "False when no recording was running.", @@ -38,13 +45,6 @@ "null" ] } - }, - "required": [ - "duration_ms", - "frames", - "ok" - ], - "title": "RecordingStopOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.regex.json b/browser/tests/golden/schemas/browser.regex.json new file mode 100644 index 000000000..c05ec3828 --- /dev/null +++ b/browser/tests/golden/schemas/browser.regex.json @@ -0,0 +1,40 @@ +{ + "function_id": "browser::regex", + "description": "Run a regex over the visible text of provided HTML; `first` returns the first match, else all.", + "request_schema": { + "type": "object", + "properties": { + "html": { + "type": "string" + }, + "pattern": { + "type": "string" + }, + "first": { + "type": "boolean" + } + }, + "required": [ + "html", + "pattern" + ] + }, + "response_schema": { + "type": "object", + "properties": { + "result": { + "type": [ + "array", + "string", + "null" + ], + "items": { + "type": [ + "string", + "null" + ] + } + } + } + } +} diff --git a/browser/tests/golden/schemas/browser.screencast.start.json b/browser/tests/golden/schemas/browser.screencast.start.json index 749b11489..d6f6e82d6 100644 --- a/browser/tests/golden/schemas/browser.screencast.start.json +++ b/browser/tests/golden/schemas/browser.screencast.start.json @@ -1,31 +1,31 @@ { - "description": "Internal: start pushing live viewport frames for browser::frame. Console-UI plumbing; agents use browser::screenshot. Not an agent function.", "function_id": "browser::screencast::start", + "description": "Internal: start pushing live viewport frames for browser::frame. Console-UI plumbing; agents use browser::screenshot. Not an agent function.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ScreencastStartInput", + "type": "object", + "required": [ + "session_id" + ], "properties": { "session_id": { "type": "string" } - }, - "required": [ - "session_id" - ], - "title": "ScreencastStartInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AckOutput", "description": "Neutral acknowledgement returned by the fire-and-forget internal functions (pick start/stop/resolve, screencast start/stop): they emit their result as a trigger event, so the direct return is just `{ ok }`.", + "type": "object", + "required": [ + "ok" + ], "properties": { "ok": { "type": "boolean" } - }, - "required": [ - "ok" - ], - "title": "AckOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.screencast.stop.json b/browser/tests/golden/schemas/browser.screencast.stop.json index 9825564a8..ad204db88 100644 --- a/browser/tests/golden/schemas/browser.screencast.stop.json +++ b/browser/tests/golden/schemas/browser.screencast.stop.json @@ -1,32 +1,32 @@ { - "description": "Internal: stop the live frame push. Idempotent. Not an agent function.", "function_id": "browser::screencast::stop", + "description": "Internal: stop the live frame push. Idempotent. Not an agent function.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ScreencastStopInput", + "type": "object", + "required": [ + "session_id" + ], "properties": { "session_id": { "description": "Stopping the screencast on an unknown session succeeds.", "type": "string" } - }, - "required": [ - "session_id" - ], - "title": "ScreencastStopInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AckOutput", "description": "Neutral acknowledgement returned by the fire-and-forget internal functions (pick start/stop/resolve, screencast start/stop): they emit their result as a trigger event, so the direct return is just `{ ok }`.", + "type": "object", + "required": [ + "ok" + ], "properties": { "ok": { "type": "boolean" } - }, - "required": [ - "ok" - ], - "title": "AckOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.screenshot-url.json b/browser/tests/golden/schemas/browser.screenshot-url.json new file mode 100644 index 000000000..d6c3ba22e --- /dev/null +++ b/browser/tests/golden/schemas/browser.screenshot-url.json @@ -0,0 +1,87 @@ +{ + "function_id": "browser::screenshot-url", + "description": "Capture a page screenshot as image content blocks via a browser fetcher (dynamic or stealthy).", + "request_schema": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "fetcher": { + "type": "string", + "enum": [ + "dynamic", + "stealthy" + ] + }, + "full_page": { + "type": "boolean" + }, + "format": { + "type": "string", + "enum": [ + "png", + "jpeg" + ] + }, + "headless": { + "type": "boolean" + }, + "network_idle": { + "type": "boolean" + }, + "timeout": { + "type": "number" + }, + "wait_selector": { + "type": "string" + }, + "proxy": { + "type": "string" + } + }, + "required": [ + "url" + ] + }, + "response_schema": { + "type": "object", + "properties": { + "content": { + "type": "array", + "description": "image blocks (one per tile, width<=1024/height<=1536) + a text caption", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image", + "text" + ] + }, + "mime": { + "type": "string" + }, + "data": { + "type": "string", + "description": "base64 image bytes (image blocks)" + }, + "text": { + "type": "string" + } + }, + "required": [ + "type" + ] + } + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + } + } +} diff --git a/browser/tests/golden/schemas/browser.screenshot.json b/browser/tests/golden/schemas/browser.screenshot.json index 4e00e2dfa..dade04951 100644 --- a/browser/tests/golden/schemas/browser.screenshot.json +++ b/browser/tests/golden/schemas/browser.screenshot.json @@ -1,12 +1,17 @@ { - "description": "Capture the session viewport as a viewable JPEG. Use browser::snapshot for machine-readable structure; screenshot when layout or rendering matters.", "function_id": "browser::screenshot", + "description": "Capture the session viewport as a viewable JPEG. Use browser::snapshot for machine-readable structure; screenshot when layout or rendering matters.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ScreenshotInput", + "type": "object", + "required": [ + "session_id" + ], "properties": { "full_page": { - "default": null, "description": "Capture the full scrollable page instead of the viewport.", + "default": null, "type": [ "boolean", "null" @@ -15,18 +20,34 @@ "session_id": { "type": "string" } - }, - "required": [ - "session_id" - ], - "title": "ScreenshotInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ScreenshotOutput", + "type": "object", + "required": [ + "content", + "details" + ], + "properties": { + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/ContentBlock" + } + }, + "details": { + "$ref": "#/definitions/ScreenshotDetails" + } + }, "definitions": { "ContentBlock": { "description": "One block of a viewable response: an image block plus a text line.", + "type": "object", + "required": [ + "type" + ], "properties": { "data": { "type": [ @@ -49,18 +70,21 @@ "type": { "type": "string" } - }, - "required": [ - "type" - ], - "type": "object" + } }, "ScreenshotDetails": { + "type": "object", + "required": [ + "height", + "session_id", + "url", + "width" + ], "properties": { "height": { + "type": "integer", "format": "uint32", - "minimum": 0.0, - "type": "integer" + "minimum": 0.0 }, "session_id": { "type": "string" @@ -69,36 +93,12 @@ "type": "string" }, "width": { + "type": "integer", "format": "uint32", - "minimum": 0.0, - "type": "integer" + "minimum": 0.0 } - }, - "required": [ - "height", - "session_id", - "url", - "width" - ], - "type": "object" + } } - }, - "properties": { - "content": { - "items": { - "$ref": "#/definitions/ContentBlock" - }, - "type": "array" - }, - "details": { - "$ref": "#/definitions/ScreenshotDetails" - } - }, - "required": [ - "content", - "details" - ], - "title": "ScreenshotOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.session-close.json b/browser/tests/golden/schemas/browser.session-close.json new file mode 100644 index 000000000..b0a84fb9d --- /dev/null +++ b/browser/tests/golden/schemas/browser.session-close.json @@ -0,0 +1,23 @@ +{ + "function_id": "browser::session-close", + "description": "Close a session and free its browser/connection.", + "request_schema": { + "type": "object", + "properties": { + "session_id": { + "type": "string" + } + }, + "required": [ + "session_id" + ] + }, + "response_schema": { + "type": "object", + "properties": { + "closed": { + "type": "boolean" + } + } + } +} diff --git a/browser/tests/golden/schemas/browser.session-fetch.json b/browser/tests/golden/schemas/browser.session-fetch.json new file mode 100644 index 000000000..9f218f030 --- /dev/null +++ b/browser/tests/golden/schemas/browser.session-fetch.json @@ -0,0 +1,151 @@ +{ + "function_id": "browser::session-fetch", + "description": "Fetch a URL on an open session (reuses its cookies/browser); same page/extraction output.", + "request_schema": { + "type": "object", + "properties": { + "session_id": { + "type": "string" + }, + "url": { + "type": "string" + }, + "method": { + "type": "string", + "enum": [ + "get", + "post", + "put", + "delete" + ] + }, + "headers": { + "type": "object" + }, + "params": { + "type": "object" + }, + "data": { + "type": "object" + }, + "json": { + "type": "object" + }, + "wait_selector": { + "type": "string" + }, + "selectors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "css": { + "type": "string" + }, + "xpath": { + "type": "string" + }, + "regex": { + "type": "string" + }, + "attr": { + "type": "string", + "description": "extract this attribute instead of text" + }, + "html": { + "type": "boolean", + "description": "extract inner HTML instead of text" + }, + "all": { + "type": "boolean", + "description": "return every match as a list" + } + }, + "required": [ + "name" + ] + } + }, + "include_html": { + "type": "boolean" + }, + "format": { + "type": "string", + "enum": [ + "markdown", + "text" + ], + "description": "render page body to this format" + }, + "main_content_only": { + "type": "boolean", + "description": "strip nav/scripts/hidden before rendering" + }, + "css_selector": { + "type": "string", + "description": "scope the render to this CSS subtree (e.g. a page's content div)" + } + }, + "required": [ + "session_id", + "url" + ] + }, + "response_schema": { + "type": "object", + "properties": { + "status": { + "type": [ + "integer", + "null" + ] + }, + "url": { + "type": "string" + }, + "headers": { + "type": "object" + }, + "cookies": { + "type": "object" + }, + "encoding": { + "type": [ + "string", + "null" + ] + }, + "extracted": { + "type": "object" + }, + "html": { + "type": "string" + }, + "content": { + "type": "string", + "description": "markdown/text render when `format` requested" + }, + "format": { + "type": "string" + }, + "captured_xhr": { + "type": "array", + "items": { + "type": "object" + } + }, + "results": { + "type": "array", + "items": { + "type": "object" + } + }, + "error": { + "type": "string" + } + } + } +} diff --git a/browser/tests/golden/schemas/browser.session-list.json b/browser/tests/golden/schemas/browser.session-list.json new file mode 100644 index 000000000..e5389b0b6 --- /dev/null +++ b/browser/tests/golden/schemas/browser.session-list.json @@ -0,0 +1,46 @@ +{ + "function_id": "browser::session-list", + "description": "List open sessions with their type and idle time.", + "request_schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "http", + "dynamic", + "stealthy" + ], + "description": "filter by type" + } + } + }, + "response_schema": { + "type": "object", + "properties": { + "sessions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "session_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "created_at": { + "type": "number" + }, + "last_used": { + "type": "number" + }, + "idle_s": { + "type": "number" + } + } + } + } + } + } +} diff --git a/browser/tests/golden/schemas/browser.session-open.json b/browser/tests/golden/schemas/browser.session-open.json new file mode 100644 index 000000000..523ef3bdb --- /dev/null +++ b/browser/tests/golden/schemas/browser.session-open.json @@ -0,0 +1,60 @@ +{ + "function_id": "browser::session-open", + "description": "Open a persistent HTTP/browser session; returns a session_id that reuses cookies + state.", + "request_schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "http", + "dynamic", + "stealthy" + ], + "description": "session engine" + }, + "impersonate": { + "type": "string" + }, + "headers": { + "type": "object" + }, + "proxy": { + "type": "string" + }, + "proxies": { + "type": "object" + }, + "headless": { + "type": "boolean" + }, + "useragent": { + "type": "string" + }, + "solve_cloudflare": { + "type": "boolean" + }, + "real_chrome": { + "type": "boolean" + }, + "timeout": { + "type": "number" + }, + "capture_xhr": { + "type": "string", + "description": "regex; capture matching XHRs (browser sessions)" + } + } + }, + "response_schema": { + "type": "object", + "properties": { + "session_id": { + "type": "string" + }, + "type": { + "type": "string" + } + } + } +} diff --git a/browser/tests/golden/schemas/browser.sessions.attach.json b/browser/tests/golden/schemas/browser.sessions.attach.json index 9831e6180..158be7ed8 100644 --- a/browser/tests/golden/schemas/browser.sessions.attach.json +++ b/browser/tests/golden/schemas/browser.sessions.attach.json @@ -1,12 +1,17 @@ { - "description": "Attach a session to an already-running browser over CDP (start Chrome with --remote-debugging-port). Opens a fresh tab the session owns, or adopts an existing user tab by URL substring and releases it untouched on stop. Reaches the real profile with its logins; disabled unless allow_attach is set in config.", "function_id": "browser::sessions::attach", + "description": "Attach a session to an already-running browser over CDP (start Chrome with --remote-debugging-port). Opens a fresh tab the session owns, or adopts an existing user tab by URL substring and releases it untouched on stop. Reaches the real profile with its logins; disabled unless allow_attach is set in config.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AttachInput", + "type": "object", + "required": [ + "cdp_url" + ], "properties": { "adopt_url_substring": { - "default": null, "description": "Adopt the existing tab whose URL contains this substring, exclusively, and release it untouched on stop. Omit to open a fresh tab the session owns and closes on stop. Must match exactly one open tab.", + "default": null, "type": [ "string", "null" @@ -17,30 +22,33 @@ "type": "string" }, "read_only": { - "default": null, "description": "Inspection-only session; see browser::sessions::start.", + "default": null, "type": [ "boolean", "null" ] }, "url": { - "default": null, "description": "URL to open in the fresh tab (ignored when adopting). Omit for about:blank.", + "default": null, "type": [ "string", "null" ] } - }, - "required": [ - "cdp_url" - ], - "title": "AttachInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AttachOutput", + "type": "object", + "required": [ + "adopted", + "read_only", + "session_id", + "url" + ], "properties": { "adopted": { "description": "True when the session adopted an existing user tab (released, not closed, on stop); false when it opened a fresh tab it owns.", @@ -55,14 +63,6 @@ "url": { "type": "string" } - }, - "required": [ - "adopted", - "read_only", - "session_id", - "url" - ], - "title": "AttachOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.sessions.list.json b/browser/tests/golden/schemas/browser.sessions.list.json index c4f171970..19195e4f7 100644 --- a/browser/tests/golden/schemas/browser.sessions.list.json +++ b/browser/tests/golden/schemas/browser.sessions.list.json @@ -1,6 +1,6 @@ { - "description": "List live browser sessions with their current URL and activity.", "function_id": "browser::sessions::list", + "description": "List live browser sessions with their current URL and activity.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "ListInput", @@ -8,24 +8,47 @@ }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ListOutput", + "type": "object", + "required": [ + "sessions" + ], + "properties": { + "sessions": { + "type": "array", + "items": { + "$ref": "#/definitions/SessionInfo" + } + } + }, "definitions": { "SessionInfo": { + "type": "object", + "required": [ + "console_entries", + "created_ms", + "headless", + "last_used_ms", + "read_only", + "session_id", + "url" + ], "properties": { "console_entries": { + "type": "integer", "format": "uint64", - "minimum": 0.0, - "type": "integer" + "minimum": 0.0 }, "created_ms": { - "format": "int64", - "type": "integer" + "type": "integer", + "format": "int64" }, "headless": { "type": "boolean" }, "last_used_ms": { - "format": "int64", - "type": "integer" + "type": "integer", + "format": "int64" }, "read_only": { "type": "boolean" @@ -42,31 +65,8 @@ "url": { "type": "string" } - }, - "required": [ - "console_entries", - "created_ms", - "headless", - "last_used_ms", - "read_only", - "session_id", - "url" - ], - "type": "object" + } } - }, - "properties": { - "sessions": { - "items": { - "$ref": "#/definitions/SessionInfo" - }, - "type": "array" - } - }, - "required": [ - "sessions" - ], - "title": "ListOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.sessions.start.json b/browser/tests/golden/schemas/browser.sessions.start.json index 290d0cff5..8c4cd1bfd 100644 --- a/browser/tests/golden/schemas/browser.sessions.start.json +++ b/browser/tests/golden/schemas/browser.sessions.start.json @@ -1,39 +1,47 @@ { - "description": "Start an interactive Chromium session and return its session_id. Sessions keep console and network history; stop them with browser::sessions::stop when done.", "function_id": "browser::sessions::start", + "description": "Start an interactive Chromium session and return its session_id. Sessions keep console and network history; stop them with browser::sessions::stop when done.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "StartInput", + "type": "object", "properties": { "headful": { - "default": null, "description": "Force a visible window for this session, overriding the configured `headless` default.", + "default": null, "type": [ "boolean", "null" ] }, "read_only": { - "default": null, "description": "Inspection-only session: act, evaluate, execute, and styles::write are rejected while navigation, snapshots, reads, and screenshots work. Immutable for the session's lifetime.", + "default": null, "type": [ "boolean", "null" ] }, "url": { - "default": null, "description": "URL to open immediately. Omit to start on about:blank.", + "default": null, "type": [ "string", "null" ] } - }, - "title": "StartInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "StartOutput", + "type": "object", + "required": [ + "headless", + "read_only", + "session_id", + "url" + ], "properties": { "headless": { "type": "boolean" @@ -48,14 +56,6 @@ "url": { "type": "string" } - }, - "required": [ - "headless", - "read_only", - "session_id", - "url" - ], - "title": "StartOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.sessions.stop.json b/browser/tests/golden/schemas/browser.sessions.stop.json index 3bcd4fd83..8e70bdaa0 100644 --- a/browser/tests/golden/schemas/browser.sessions.stop.json +++ b/browser/tests/golden/schemas/browser.sessions.stop.json @@ -1,22 +1,28 @@ { - "description": "Stop a browser session and its Chromium process. Idempotent: stopping an unknown or already-stopped session succeeds with was_running=false.", "function_id": "browser::sessions::stop", + "description": "Stop a browser session and its Chromium process. Idempotent: stopping an unknown or already-stopped session succeeds with was_running=false.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "StopInput", + "type": "object", + "required": [ + "session_id" + ], "properties": { "session_id": { "description": "Session to stop. Stopping an unknown or already-stopped id succeeds.", "type": "string" } - }, - "required": [ - "session_id" - ], - "title": "StopInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "StopOutput", + "type": "object", + "required": [ + "ok", + "was_running" + ], "properties": { "ok": { "type": "boolean" @@ -25,12 +31,6 @@ "description": "False when the session was already gone.", "type": "boolean" } - }, - "required": [ - "ok", - "was_running" - ], - "title": "StopOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.snapshot.json b/browser/tests/golden/schemas/browser.snapshot.json index cfeec70b9..7793beb12 100644 --- a/browser/tests/golden/schemas/browser.snapshot.json +++ b/browser/tests/golden/schemas/browser.snapshot.json @@ -1,12 +1,17 @@ { - "description": "Read the page as an accessibility-tree outline. Lines carry [ref=eN] handles that browser::act accepts; refs stay valid until the next navigation. Prefer this over browser::screenshot; it is cheaper and machine-readable.", "function_id": "browser::snapshot", + "description": "Read the page as an accessibility-tree outline. Lines carry [ref=eN] handles that browser::act accepts; refs stay valid until the next navigation. Prefer this over browser::screenshot; it is cheaper and machine-readable.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SnapshotInput", + "type": "object", + "required": [ + "session_id" + ], "properties": { "diff": { - "default": null, "description": "Return only what changed since this session's previous snapshot instead of the full outline. Falls back to a full snapshot when there is no baseline (first snapshot, or first after a navigation).", + "default": null, "type": [ "boolean", "null" @@ -15,47 +20,21 @@ "session_id": { "type": "string" } - }, - "required": [ - "session_id" - ], - "title": "SnapshotInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "SnapshotDiff": { - "description": "Changes since the previous snapshot. Lines are compared without their `[ref=eN]` suffix (ref names are unique per snapshot); `added` lines carry current refs and are directly actionable.", - "properties": { - "added": { - "items": { - "type": "string" - }, - "type": "array" - }, - "removed": { - "items": { - "type": "string" - }, - "type": "array" - }, - "unchanged": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "added", - "removed", - "unchanged" - ], - "type": "object" - } - }, + "title": "SnapshotOutput", + "type": "object", + "required": [ + "generation", + "tree", + "truncated", + "url" + ], "properties": { "diff": { + "description": "Present when the caller asked for `diff: true` and a baseline existed. Covers only the emitted nodes of both snapshots; check `truncated` before trusting it as a complete change set.", "anyOf": [ { "$ref": "#/definitions/SnapshotDiff" @@ -63,14 +42,13 @@ { "type": "null" } - ], - "description": "Present when the caller asked for `diff: true` and a baseline existed. Covers only the emitted nodes of both snapshots; check `truncated` before trusting it as a complete change set." + ] }, "generation": { "description": "Document generation the refs belong to; navigation advances it and kills every ref from earlier generations.", + "type": "integer", "format": "uint64", - "minimum": 0.0, - "type": "integer" + "minimum": 0.0 }, "title": { "type": [ @@ -90,13 +68,35 @@ "type": "string" } }, - "required": [ - "generation", - "tree", - "truncated", - "url" - ], - "title": "SnapshotOutput", - "type": "object" + "definitions": { + "SnapshotDiff": { + "description": "Changes since the previous snapshot. Lines are compared without their `[ref=eN]` suffix (ref names are unique per snapshot); `added` lines carry current refs and are directly actionable.", + "type": "object", + "required": [ + "added", + "removed", + "unchanged" + ], + "properties": { + "added": { + "type": "array", + "items": { + "type": "string" + } + }, + "removed": { + "type": "array", + "items": { + "type": "string" + } + }, + "unchanged": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + } } } diff --git a/browser/tests/golden/schemas/browser.stealthy-fetch.json b/browser/tests/golden/schemas/browser.stealthy-fetch.json new file mode 100644 index 000000000..0a6a1d957 --- /dev/null +++ b/browser/tests/golden/schemas/browser.stealthy-fetch.json @@ -0,0 +1,221 @@ +{ + "function_id": "browser::stealthy-fetch", + "description": "Camoufox stealth browser: solves Cloudflare, hardens WebRTC/canvas; extraction + bulk.", + "request_schema": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "urls": { + "type": "array", + "items": { + "type": "string" + } + }, + "headless": { + "type": "boolean" + }, + "network_idle": { + "type": "boolean" + }, + "load_dom": { + "type": "boolean" + }, + "timeout": { + "type": "number", + "description": "milliseconds (browser fetcher)" + }, + "wait": { + "type": "number", + "description": "extra ms to wait after load" + }, + "wait_selector": { + "type": "string" + }, + "wait_selector_state": { + "type": "string", + "enum": [ + "attached", + "detached", + "visible", + "hidden" + ] + }, + "disable_resources": { + "type": "boolean" + }, + "block_ads": { + "type": "boolean" + }, + "blocked_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "proxy": { + "type": "string" + }, + "useragent": { + "type": "string" + }, + "cookies": { + "type": "object" + }, + "extra_headers": { + "type": "object" + }, + "google_search": { + "type": "boolean" + }, + "capture_xhr": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "timezone_id": { + "type": "string" + }, + "dns_over_https": { + "type": "boolean" + }, + "extra_flags": { + "type": "array", + "items": { + "type": "string" + } + }, + "max_pages": { + "type": "integer" + }, + "retries": { + "type": "integer" + }, + "retry_delay": { + "type": "number" + }, + "solve_cloudflare": { + "type": "boolean" + }, + "block_webrtc": { + "type": "boolean" + }, + "hide_canvas": { + "type": "boolean" + }, + "allow_webgl": { + "type": "boolean" + }, + "selectors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "css": { + "type": "string" + }, + "xpath": { + "type": "string" + }, + "regex": { + "type": "string" + }, + "attr": { + "type": "string", + "description": "extract this attribute instead of text" + }, + "html": { + "type": "boolean", + "description": "extract inner HTML instead of text" + }, + "all": { + "type": "boolean", + "description": "return every match as a list" + } + }, + "required": [ + "name" + ] + } + }, + "include_html": { + "type": "boolean" + }, + "format": { + "type": "string", + "enum": [ + "markdown", + "text" + ], + "description": "render page body to this format" + }, + "main_content_only": { + "type": "boolean", + "description": "strip nav/scripts/hidden before rendering" + }, + "css_selector": { + "type": "string", + "description": "scope the render to this CSS subtree (e.g. a page's content div)" + } + } + }, + "response_schema": { + "type": "object", + "properties": { + "status": { + "type": [ + "integer", + "null" + ] + }, + "url": { + "type": "string" + }, + "headers": { + "type": "object" + }, + "cookies": { + "type": "object" + }, + "encoding": { + "type": [ + "string", + "null" + ] + }, + "extracted": { + "type": "object" + }, + "html": { + "type": "string" + }, + "content": { + "type": "string", + "description": "markdown/text render when `format` requested" + }, + "format": { + "type": "string" + }, + "captured_xhr": { + "type": "array", + "items": { + "type": "object" + } + }, + "results": { + "type": "array", + "items": { + "type": "object" + } + }, + "error": { + "type": "string" + } + } + } +} diff --git a/browser/tests/golden/schemas/browser.styles.read.json b/browser/tests/golden/schemas/browser.styles.read.json index 3dee4c197..d092df7c2 100644 --- a/browser/tests/golden/schemas/browser.styles.read.json +++ b/browser/tests/golden/schemas/browser.styles.read.json @@ -1,19 +1,25 @@ { - "description": "Read an element's computed styles (curated design set by default, or named properties) plus its inline style attribute.", "function_id": "browser::styles::read", + "description": "Read an element's computed styles (curated design set by default, or named properties) plus its inline style attribute.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "StylesReadInput", + "type": "object", + "required": [ + "ref", + "session_id" + ], "properties": { "properties": { - "default": null, "description": "Computed property names to return. Omit for a curated design-panel set; pass `[\"*\"]` for every computed property.", - "items": { - "type": "string" - }, + "default": null, "type": [ "array", "null" - ] + ], + "items": { + "type": "string" + } }, "ref": { "description": "Element ref from `browser::snapshot`, `browser::dom::read`, or a pick.", @@ -22,33 +28,16 @@ "session_id": { "type": "string" } - }, - "required": [ - "ref", - "session_id" - ], - "title": "StylesReadInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "StyleProperty": { - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "name", - "value" - ], - "type": "object" - } - }, + "title": "StylesReadOutput", + "type": "object", + "required": [ + "properties", + "ref" + ], "properties": { "inline_style": { "description": "The element's inline `style` attribute, when present.", @@ -58,20 +47,31 @@ ] }, "properties": { + "type": "array", "items": { "$ref": "#/definitions/StyleProperty" - }, - "type": "array" + } }, "ref": { "type": "string" } }, - "required": [ - "properties", - "ref" - ], - "title": "StylesReadOutput", - "type": "object" + "definitions": { + "StyleProperty": { + "type": "object", + "required": [ + "name", + "value" + ], + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } } } diff --git a/browser/tests/golden/schemas/browser.styles.write.json b/browser/tests/golden/schemas/browser.styles.write.json index c4ed6a12e..ea94de569 100644 --- a/browser/tests/golden/schemas/browser.styles.write.json +++ b/browser/tests/golden/schemas/browser.styles.write.json @@ -1,12 +1,20 @@ { - "description": "Set one inline CSS property on an element, live in the page. Visual experiment only: the page's source files are untouched, and the edit dies with the next navigation.", "function_id": "browser::styles::write", + "description": "Set one inline CSS property on an element, live in the page. Visual experiment only: the page's source files are untouched, and the edit dies with the next navigation.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "StylesWriteInput", + "type": "object", + "required": [ + "property", + "ref", + "session_id", + "value" + ], "properties": { "important": { - "default": null, "description": "Apply with `!important`.", + "default": null, "type": [ "boolean", "null" @@ -27,18 +35,16 @@ "description": "CSS value (`#101418`). Empty string removes the inline property.", "type": "string" } - }, - "required": [ - "property", - "ref", - "session_id", - "value" - ], - "title": "StylesWriteInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "StylesWriteOutput", + "type": "object", + "required": [ + "inline_style", + "ok" + ], "properties": { "inline_style": { "description": "The element's inline `style` attribute after the edit.", @@ -47,12 +53,6 @@ "ok": { "type": "boolean" } - }, - "required": [ - "inline_style", - "ok" - ], - "title": "StylesWriteOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.tabs.list.json b/browser/tests/golden/schemas/browser.tabs.list.json index 39cb55948..23332df8e 100644 --- a/browser/tests/golden/schemas/browser.tabs.list.json +++ b/browser/tests/golden/schemas/browser.tabs.list.json @@ -1,25 +1,43 @@ { - "description": "List the open tabs of a running browser reachable at a CDP endpoint (url, title, and whether a session already adopted each). Read-only; adopt one with browser::sessions::attach.", "function_id": "browser::tabs::list", + "description": "List the open tabs of a running browser reachable at a CDP endpoint (url, title, and whether a session already adopted each). Read-only; adopt one with browser::sessions::attach.", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TabsListInput", + "type": "object", + "required": [ + "cdp_url" + ], "properties": { "cdp_url": { "description": "CDP endpoint of the running browser, as in browser::sessions::attach.", "type": "string" } - }, - "required": [ - "cdp_url" - ], - "title": "TabsListInput", - "type": "object" + } }, "response_schema": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TabsListOutput", + "type": "object", + "required": [ + "tabs" + ], + "properties": { + "tabs": { + "type": "array", + "items": { + "$ref": "#/definitions/TabInfo" + } + } + }, "definitions": { "TabInfo": { "description": "One open tab reported by `browser::tabs::list`.", + "type": "object", + "required": [ + "adopted", + "url" + ], "properties": { "adopted": { "description": "True when a session already adopted this tab; it cannot be adopted again until that session stops.", @@ -34,26 +52,8 @@ "url": { "type": "string" } - }, - "required": [ - "adopted", - "url" - ], - "type": "object" - } - }, - "properties": { - "tabs": { - "items": { - "$ref": "#/definitions/TabInfo" - }, - "type": "array" + } } - }, - "required": [ - "tabs" - ], - "title": "TabsListOutput", - "type": "object" + } } } diff --git a/browser/tests/golden/schemas/browser.to-markdown.json b/browser/tests/golden/schemas/browser.to-markdown.json new file mode 100644 index 000000000..0c2ceb2ed --- /dev/null +++ b/browser/tests/golden/schemas/browser.to-markdown.json @@ -0,0 +1,42 @@ +{ + "function_id": "browser::to-markdown", + "description": "Convert HTML to compact Markdown (or text/html); optional CSS scope + main-content clean.", + "request_schema": { + "type": "object", + "properties": { + "html": { + "type": "string" + }, + "format": { + "type": "string", + "enum": [ + "markdown", + "text", + "html" + ] + }, + "css_selector": { + "type": "string", + "description": "convert only the subtree matching this CSS selector" + }, + "main_content_only": { + "type": "boolean", + "description": "strip nav/scripts/hidden nodes first" + } + }, + "required": [ + "html" + ] + }, + "response_schema": { + "type": "object", + "properties": { + "format": { + "type": "string" + }, + "content": { + "type": "string" + } + } + } +} diff --git a/browser/tests/golden/schemas/browser.xpath.json b/browser/tests/golden/schemas/browser.xpath.json new file mode 100644 index 000000000..bbd9cb999 --- /dev/null +++ b/browser/tests/golden/schemas/browser.xpath.json @@ -0,0 +1,59 @@ +{ + "function_id": "browser::xpath", + "description": "One XPath query over HTML; first-or-all; `attr` pulls an attribute else text.", + "request_schema": { + "type": "object", + "properties": { + "html": { + "type": "string" + }, + "query": { + "type": "string" + }, + "first": { + "type": "boolean" + }, + "attr": { + "type": "string" + }, + "identifier": { + "type": "string", + "description": "stable key for the saved element" + }, + "adaptive": { + "type": "boolean", + "description": "relocate elements after a site change via saved identities" + }, + "auto_save": { + "type": "boolean", + "description": "save matched identities (defaults on when adaptive)" + }, + "adaptive_domain": { + "type": "string", + "description": "page URL/domain that keys saved identities" + } + }, + "required": [ + "html", + "query" + ] + }, + "response_schema": { + "type": "object", + "properties": { + "result": { + "type": [ + "array", + "string", + "null" + ], + "items": { + "type": [ + "string", + "null" + ] + } + } + } + } +} diff --git a/browser/tests/integration.rs b/browser/tests/integration.rs index b203dbb05..68945ffc5 100644 --- a/browser/tests/integration.rs +++ b/browser/tests/integration.rs @@ -2,6 +2,8 @@ //! `browser::*` surface via iii-sdk as a client. Self-skips when `iii` or a //! Chromium executable is absent, so CI hosts without either stay green. +use std::net::TcpListener; +use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::time::Duration; @@ -10,11 +12,11 @@ use iii_sdk::{register_worker, InitOptions}; use serde_json::json; use tokio::time::{sleep, timeout}; -const ENGINE_WS: &str = "ws://127.0.0.1:49134"; - struct Harness { iii: Child, worker: Child, + engine_ws: String, + config_path: PathBuf, } impl Drop for Harness { @@ -23,6 +25,7 @@ impl Drop for Harness { let _ = self.worker.wait(); let _ = self.iii.kill(); let _ = self.iii.wait(); + let _ = std::fs::remove_file(&self.config_path); } } @@ -46,8 +49,26 @@ async fn boot() -> Option<Harness> { return None; } + let port = TcpListener::bind("127.0.0.1:0") + .ok()? + .local_addr() + .ok()? + .port(); + let engine_ws = format!("ws://127.0.0.1:{port}"); + let config_path = std::env::temp_dir().join(format!( + "browser-integration-{}.yaml", + uuid::Uuid::new_v4().simple() + )); + std::fs::write( + &config_path, + format!( + "workers:\n - name: iii-worker-manager\n config:\n host: 127.0.0.1\n port: {port}\nmodules: []\n" + ), + ) + .ok()?; + let mut iii = Command::new(&iii_bin) - .arg("--use-default-config") + .args(["--config", config_path.to_str()?, "--no-update-check"]) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() @@ -57,7 +78,7 @@ async fn boot() -> Option<Harness> { let worker = match Command::new(env!("CARGO_BIN_EXE_browser")) .arg("--url") - .arg(ENGINE_WS) + .arg(&engine_ws) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() @@ -68,23 +89,29 @@ async fn boot() -> Option<Harness> { // constructed), so clean up the already-started engine here. let _ = iii.kill(); let _ = iii.wait(); + let _ = std::fs::remove_file(config_path); return None; } }; sleep(Duration::from_millis(1500)).await; - Some(Harness { iii, worker }) + Some(Harness { + iii, + worker, + engine_ws, + config_path, + }) } #[tokio::test] async fn session_lifecycle_console_and_snapshot() { - let Some(_h) = boot().await else { + let Some(h) = boot().await else { eprintln!("skipping: `iii` or Chromium not available"); return; }; - let client = register_worker(ENGINE_WS, InitOptions::default()); + let client = register_worker(&h.engine_ws, InitOptions::default()); sleep(Duration::from_millis(500)).await; let call = |function_id: &str, payload: serde_json::Value, timeout_ms: u64| { @@ -255,7 +282,6 @@ async fn session_lifecycle_console_and_snapshot() { tabs.is_err(), "tabs::list must be refused when allow_attach is false: {tabs:?}" ); - // stop is idempotent let stopped = call( "browser::sessions::stop", diff --git a/browser/tests/scrapling_schemas.rs b/browser/tests/scrapling_schemas.rs new file mode 100644 index 000000000..5924f6ab7 --- /dev/null +++ b/browser/tests/scrapling_schemas.rs @@ -0,0 +1,90 @@ +//! Wire-schema snapshots for the `browser::*` Scrapling surface: catalog() +//! vs Python-generated goldens, byte-for-byte. +//! +//! These goldens are read-only (see `support::check_golden_readonly`) — they +//! come from `scripts/gen_goldens.py` running real scrapling 0.4.9, so a pass +//! means the Rust literals still mirror `scrapling/src/schemas.py`. The +//! `browser::` id prefix is the one deliberate divergence; the generator +//! applies it via `wire_id()`. + +mod support; + +use browser::scrapling::schemas::{catalog, FunctionSpec}; + +fn spec_to_pretty_json(spec: &FunctionSpec) -> String { + let value = serde_json::json!({ + "function_id": spec.function_id, + "description": spec.description, + "request_schema": spec.request, + "response_schema": spec.response, + }); + let mut pretty = serde_json::to_string_pretty(&value).expect("serializes"); + pretty.push('\n'); + pretty +} + +#[test] +fn catalog_lists_all_scraping_functions_at_browser_root() { + let ids: Vec<&str> = catalog().iter().map(|s| s.function_id).collect(); + assert_eq!( + ids, + vec![ + "browser::fetch", + "browser::stealthy-fetch", + "browser::dynamic-fetch", + "browser::screenshot-url", + "browser::extract", + "browser::css", + "browser::xpath", + "browser::regex", + "browser::find-similar", + "browser::find", + "browser::find-by-text", + "browser::find-by-regex", + "browser::describe", + "browser::to-markdown", + "browser::session-open", + "browser::session-fetch", + "browser::session-close", + "browser::session-list", + "browser::crawl", + ] + ); +} + +#[test] +fn wire_schemas_match_python_goldens_byte_for_byte() { + let mut failures = Vec::new(); + for spec in catalog() { + let rel = format!("schemas/{}.json", spec.function_id.replace("::", ".")); + if let Err(msg) = support::check_golden_readonly(&rel, &spec_to_pretty_json(&spec)) { + failures.push(msg); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n\n")); +} + +#[test] +fn every_schema_is_typed() { + for spec in catalog() { + support::assert_typed_schema_value(&format!("{} request", spec.function_id), &spec.request); + support::assert_typed_schema_value( + &format!("{} response", spec.function_id), + &spec.response, + ); + } +} + +#[test] +fn static_ids_are_catalog_plus_guidance_hook() { + let mut expected: Vec<&str> = catalog().iter().map(|s| s.function_id).collect(); + expected.push("browser::inject-guidance"); + assert_eq!(browser::scrapling::STATIC_IDS, expected.as_slice()); +} + +#[test] +fn registered_wire_ids_do_not_use_the_old_nested_namespace() { + assert!(browser::scrapling::STATIC_IDS + .iter() + .all(|id| !id.starts_with("browser::scrapling::"))); +} diff --git a/browser/tests/support/mod.rs b/browser/tests/support/mod.rs index 440e3bf0e..8d42642b6 100644 --- a/browser/tests/support/mod.rs +++ b/browser/tests/support/mod.rs @@ -89,6 +89,41 @@ fn diff_hint(rel: &str, expected: &str, actual: &str) -> String { out } +/// Compare against a golden that this repo does NOT write: everything under +/// `tests/golden/schemas/browser.scrapling.*` and `tests/golden/behavior/` is +/// produced only by `scripts/gen_goldens.py` running the reference Python +/// implementation. There is deliberately no `UPDATE_GOLDENS` branch here — a +/// pass has to mean "Rust agrees with Python", and a writable escape hatch +/// would let a real divergence be papered over instead of root-caused. +/// Regenerate with: +/// ~/.iii/managed/scrapling/usr/local/bin/python3.12 scripts/gen_goldens.py +pub fn check_golden_readonly(rel: &str, actual: &str) -> Result<(), String> { + let path = golden_root().join(rel); + let expected = fs::read_to_string(&path).map_err(|e| { + format!( + "golden {} unreadable ({e}); regenerate with \ + ~/.iii/managed/scrapling/usr/local/bin/python3.12 scripts/gen_goldens.py", + path.display() + ) + })?; + if expected == actual { + return Ok(()); + } + Err(diff_hint(rel, &expected, actual)) +} + +/// `assert_typed_schema` for the scrapling catalog, whose schemas are raw +/// Python-mirrored `Value` literals rather than schemars output. +pub fn assert_typed_schema_value(label: &str, schema: &serde_json::Value) { + let obj = schema + .as_object() + .unwrap_or_else(|| panic!("{label}: not an object")); + assert!( + obj.contains_key("type") || obj.contains_key("properties") || obj.contains_key("$ref"), + "{label}: untyped (AnyValue) schema" + ); +} + /// Assert a schemars-derived request/response schema is a *real* schema and /// not the permissive `AnyValue` schema a `Value` handler emits (the "unknown" /// schema this whole convention exists to prevent). A real schema carries at diff --git a/browser/tests/xmloxide_compat.rs b/browser/tests/xmloxide_compat.rs new file mode 100644 index 000000000..5de3e692b --- /dev/null +++ b/browser/tests/xmloxide_compat.rs @@ -0,0 +1,92 @@ +use xmloxide::html::{parse_html_with_options, HtmlParseOptions}; +use xmloxide::serial::html::serialize_html_subtree; +use xmloxide::tree::NodeKind; + +fn parse(input: &str) -> xmloxide::Document { + parse_html_with_options( + input, + &HtmlParseOptions::default().recover(true).no_blanks(true), + ) + .unwrap() +} + +fn first(doc: &xmloxide::Document, name: &str) -> xmloxide::NodeId { + std::iter::once(doc.root()) + .chain(doc.descendants(doc.root())) + .find(|id| doc.node_name(*id) == Some(name)) + .unwrap() +} + +#[test] +fn html_recovery_and_subtree_serialization_match_the_oracle() { + let cases = [ + ( + "<table><td>A<td>B<div>C", + "table", + "<table><td>A</td><td>B<div>C</div></td></table>", + ), + ( + "<p><b>one<i>two</b>three</i>tail", + "body", + "<body><p><b>one<i>two</i></b>threetail</p></body>", + ), + ( + "<svg viewBox='0 0 1 1'><foreignObject><DIV xlink:href='x'>T</DIV></foreignObject></svg>", + "svg", + "<svg viewbox=\"0 0 1 1\"><foreignobject><div xlink:href=\"x\">T</div></foreignobject></svg>", + ), + ( + "<template><table><td>T</template><p>P", + "template", + "<template><table><td>T<p>P</p></td></table></template>", + ), + ]; + for (input, tag, expected) in cases { + let doc = parse(input); + assert_eq!(serialize_html_subtree(&doc, first(&doc, tag)), expected); + } +} + +#[test] +fn parser_preserves_attribute_order_and_first_duplicate() { + let doc = parse("<input z=1 disabled a='' z=2 checked=checked>"); + let input = first(&doc, "input"); + let attrs: Vec<_> = doc + .attributes(input) + .iter() + .map(|attr| (attr.name.as_str(), attr.value.as_str())) + .collect(); + assert_eq!( + attrs, + [ + ("z", "1"), + ("disabled", "disabled"), + ("a", ""), + ("checked", "checked") + ] + ); + assert_eq!( + serialize_html_subtree(&doc, input), + "<input z=\"1\" disabled a=\"\" checked>" + ); +} + +#[test] +fn parser_can_drop_comments_and_cdata_like_scrapling() { + let mut doc = parse("<p>a<!--gone-->b<![CDATA[c]]>d</p>"); + let removed: Vec<_> = doc + .descendants(doc.root()) + .filter(|id| { + matches!( + doc.node(*id).kind, + NodeKind::Comment { .. } | NodeKind::CData { .. } + ) + }) + .collect(); + for id in removed { + doc.remove_node(id); + } + let p = first(&doc, "p"); + assert_eq!(doc.text_content(p), "abd"); + assert_eq!(serialize_html_subtree(&doc, p), "<p>abd</p>"); +} diff --git a/browser/ui/package.json b/browser/ui/package.json index 2c18f7df1..a1330a2e5 100644 --- a/browser/ui/package.json +++ b/browser/ui/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "build": "tsc --noEmit && node build.mjs", + "test": "vitest run", "watch": "node build.mjs --watch" }, "dependencies": { @@ -14,6 +15,8 @@ "devDependencies": { "@types/react": "^19.2.14", "esbuild": "^0.25.0", - "typescript": "^5.9.2" + "react": "^19.2.6", + "typescript": "^5.9.2", + "vitest": "^4.1.6" } } diff --git a/browser/ui/page.tsx b/browser/ui/page.tsx index 69cc8c25c..6adde271c 100644 --- a/browser/ui/page.tsx +++ b/browser/ui/page.tsx @@ -21,6 +21,7 @@ import type { Host } from '@iii-dev/console-ui' import { BrowserConfigForm } from './src/configuration' import { createBrowserRenderer, createBrowserScreenshotRenderer } from './src/function-trigger-message' +import { createScraplingRenderer } from './src/function-trigger-message/scrapling' import { BrowserPage } from './src/page' export default function setup(host: Host) { @@ -36,5 +37,6 @@ export default function setup(host: Host) { // renderer first; the general browser renderer still owns errors/running // states and every other browser::* function. host.functionTriggers.register(createBrowserScreenshotRenderer()) + host.functionTriggers.register(createScraplingRenderer(host)) host.functionTriggers.register(createBrowserRenderer(host)) } diff --git a/browser/ui/src/configuration/index.tsx b/browser/ui/src/configuration/index.tsx index ea3684d16..f645417cc 100644 --- a/browser/ui/src/configuration/index.tsx +++ b/browser/ui/src/configuration/index.tsx @@ -9,7 +9,7 @@ import { type ReactNode, useEffect, useRef, useState } from 'react' import { ChevronLeftIcon, GlobeIcon, useContainerNarrow } from '../lib/widgets' type JsonObject = { [key: string]: JsonValue } -type SectionId = 'launch' | 'viewport' | 'limits' | 'behavior' +type SectionId = 'launch' | 'viewport' | 'limits' | 'behavior' | 'scraping' const CONFIG_NARROW_BELOW = 660 const DEFAULTS = { @@ -46,6 +46,7 @@ const FIELD_SECTION: Record<string, SectionId> = { max_timeout_ms: 'behavior', idle_stop_ms: 'behavior', allowed_schemes: 'behavior', + scrapling: 'scraping', } function asObject(value: JsonValue | undefined): JsonObject { @@ -333,6 +334,12 @@ function ConfigNav({ description: 'Timeouts and navigation', summary: `${formatDuration(timeout)} · idle ${formatDuration(idle)}`, }, + { + id: 'scraping', + label: 'Scraping', + description: 'Fetch tiers and agent guidance', + summary: `guidance ${booleanValue(asObject(value.scrapling).inject_guidance, true) ? 'on' : 'off'}`, + }, ] return ( @@ -452,6 +459,10 @@ function ConfigEditor({ title: 'Runtime behavior', description: 'Control timeouts, idle cleanup, and allowed destinations.', }, + scraping: { + title: 'Scraping', + description: 'Settings for the browser::* fetch and parse surface.', + }, } return ( @@ -685,6 +696,24 @@ function ConfigEditor({ </section> </> ) : null} + + {selection === 'scraping' ? ( + <section className="br-cfg-section"> + <SectionHeader + title="Agent guidance" + description="Whether the worker teaches agents its scraping surface via the system prompt." + /> + <CheckField + field="scrapling.inject_guidance" + label="Inject scraping guidance into agent system prompts" + hint="Hot-applies on save: turning this off unbinds the pre-generate hook immediately, no worker restart." + checked={booleanValue(asObject(value.scrapling).inject_guidance, true)} + onChange={(next) => + onChange({ ...value, scrapling: { ...asObject(value.scrapling), inject_guidance: next } }) + } + /> + </section> + ) : null} </div> </section> ) diff --git a/browser/ui/src/function-trigger-message/index.tsx b/browser/ui/src/function-trigger-message/index.tsx index a458c528e..43859aeb6 100644 --- a/browser/ui/src/function-trigger-message/index.tsx +++ b/browser/ui/src/function-trigger-message/index.tsx @@ -45,7 +45,7 @@ const BROWSER_PAGE_HASH = '#/ext/browser' * Header label for `browser::*` ids: dims the namespace prefix so the op * (`navigate`, `act`, …) reads clearly. */ -function FunctionIdLabel({ functionId }: { functionId: string }) { +export function FunctionIdLabel({ functionId }: { functionId: string }) { if (!functionId.startsWith('browser::')) { return <span style={{ color: 'var(--color-ink)' }}>{functionId}</span> } diff --git a/browser/ui/src/function-trigger-message/scrapling/CrawlView.tsx b/browser/ui/src/function-trigger-message/scrapling/CrawlView.tsx new file mode 100644 index 000000000..84ad3d96d --- /dev/null +++ b/browser/ui/src/function-trigger-message/scrapling/CrawlView.tsx @@ -0,0 +1,188 @@ +import { cn } from '../../lib/cn' +import { + ActionLine, + Chip, + FilterChip, + MetaRow, + StatusPill, +} from '../../lib/shared' +import { + type CrawlItem, + type CrawlRequest, + crawlRequestSchema, + crawlResponseSchema, + safeParseRequest, + safeParseResponse, +} from './parsers' + +const MAX_ITEM_ROWS = 20 + +function startCount(req: CrawlRequest): number { + // ?? only guards null/undefined, so an empty start_urls array must fall + // through to the single `url` seed (the worker does the same). + if (req.start_urls && req.start_urls.length > 0) return req.start_urls.length + return req.url ? 1 : 0 +} + +function crawlChips(req: CrawlRequest) { + return ( + <> + <Chip>{req.fetcher ?? 'http'}</Chip> + <FilterChip label="seeds" value={startCount(req)} /> + {typeof req.max_pages === 'number' ? ( + <FilterChip label="max" value={`${req.max_pages}p`} /> + ) : null} + {typeof req.max_depth === 'number' ? ( + <FilterChip label="depth" value={req.max_depth} /> + ) : null} + {req.selectors?.length ? ( + <FilterChip label="selectors" value={req.selectors.length} /> + ) : null} + {req.allowed_domains?.length ? ( + <FilterChip label="domains" value={req.allowed_domains.join(', ')} /> + ) : req.same_domain === false ? ( + <Chip className="br-ui-scrape-warning"> + <span>off-domain</span> + </Chip> + ) : null} + </> + ) +} + +export function CrawlView({ + input, + output, + running, +}: { + input: unknown + output: unknown + running?: boolean +}) { + const req = safeParseRequest(crawlRequestSchema, input) + if (!req) return null + + if (running) { + return ( + <div className="br-ui-scrape-section"> + <MetaRow> + <StatusPill label="crawling…" variant="default" /> + {crawlChips(req)} + </MetaRow> + <div className="br-ui-scrape-running"> + · walking the site… + </div> + </div> + ) + } + + const res = safeParseResponse(crawlResponseSchema, output) + if (!res) return null + const { stats } = res + return ( + <div className="br-ui-scrape-section"> + <MetaRow> + <StatusPill + label={`${stats.items} items`} + variant={stats.items ? 'accent' : 'warn'} + /> + <FilterChip label="crawled" value={stats.crawled} /> + {stats.errors > 0 ? ( + <Chip className="br-ui-scrape-warning"> + <span>{stats.errors} err</span> + </Chip> + ) : null} + {stats.stopped && stats.stopped !== 'done' ? ( + <Chip className="br-ui-scrape-warning"> + <span>{stats.stopped}</span> + </Chip> + ) : null} + {crawlChips(req)} + </MetaRow> + {res.stream?.name ? ( + <ActionLine symbol="≈" tone="accent"> + <span className="br-ui-scrape-detail"> + stream {res.stream.name} + {res.stream.group_id ? ` · ${res.stream.group_id}` : ''} + </span> + </ActionLine> + ) : null} + {res.items && res.items.length > 0 ? ( + <div> + <div className="br-ui-scrape-label"> + sample · {res.items.length} + </div> + {res.items.slice(0, MAX_ITEM_ROWS).map((item, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static wire snapshot; rows never reorder and urls may repeat + <CrawlRow key={`${i}:${item.url ?? ''}`} item={item} /> + ))} + </div> + ) : null} + </div> + ) +} + +function CrawlRow({ item }: { item: CrawlItem }) { + const summary = item.error + ? item.error + : item.extracted + ? Object.entries(item.extracted) + .map(([k, v]) => `${k}=${valuePreview(v)}`) + .join(' ') + : '' + return ( + <div className="br-ui-scrape-result-row"> + <span + className={cn( + 'br-ui-scrape-row-status', + item.error && 'is-warn', + )} + > + {item.error ? '✗' : (item.status ?? '·')} + </span> + <div className="br-ui-scrape-row-main"> + <div className="br-ui-scrape-break">{item.url}</div> + {summary ? ( + <div + className={cn( + 'br-ui-scrape-summary', + item.error && 'is-warn', + )} + > + {summary} + </div> + ) : null} + </div> + </div> + ) +} + +function valuePreview(v: unknown): string { + if (v == null) return '∅' + if (Array.isArray(v)) return `[${v.length}]` + return String(v).slice(0, 60) +} + +export function CrawlPreview({ input }: { input: unknown }) { + const req = safeParseRequest(crawlRequestSchema, input) + if (!req) return null + const seeds = + req.start_urls && req.start_urls.length > 0 + ? req.start_urls + : req.url + ? [req.url] + : [] + return ( + <div className="br-ui-scrape-section is-preview"> + <MetaRow> + <StatusPill label="permission to crawl" variant="warn" /> + {crawlChips(req)} + </MetaRow> + {seeds.slice(0, 5).map((u, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static wire snapshot; seed list is fixed + <ActionLine key={`${i}:${u}`} symbol="→" tone="ink"> + <span className="br-ui-scrape-break">{u}</span> + </ActionLine> + ))} + </div> + ) +} diff --git a/browser/ui/src/function-trigger-message/scrapling/FetchView.tsx b/browser/ui/src/function-trigger-message/scrapling/FetchView.tsx new file mode 100644 index 000000000..4260a4217 --- /dev/null +++ b/browser/ui/src/function-trigger-message/scrapling/FetchView.tsx @@ -0,0 +1,347 @@ +import { JsonHighlight } from '@iii-dev/console-ui' +import { cn } from '../../lib/cn' +import { + ActionLine, + Chip, + FilterChip, + MetaRow, + StatusPill, +} from '../../lib/shared' +import { + type FetchRequest, + fetchEngineLabel, + fetchRequestSchema, + fetchResponseSchema, + formatChars, + type PageResult, + safeParseRequest, + safeParseResponse, +} from './parsers' + +const MAX_TARGET_LINES = 5 +const HTML_PREVIEW_CHARS = 1500 + +interface FetchViewProps { + functionId: string + input: unknown + output: unknown + running?: boolean +} + +/** Shared by `browser::fetch`, `::stealthy-fetch`, and `::dynamic-fetch` — + * same page/bulk response shape; only the request chips differ per engine. */ +export function FetchView({ + functionId, + input, + output, + running, +}: FetchViewProps) { + const req = safeParseRequest(fetchRequestSchema, input) + if (!req) return null + + if (running) { + return ( + <div className="br-ui-scrape-section"> + <MetaRow> + <StatusPill label="fetching…" variant="default" /> + <OptionChips functionId={functionId} req={req} /> + </MetaRow> + <TargetLines urls={targetUrls(req)} /> + <div className="br-ui-scrape-running"> + · waiting for page… + </div> + </div> + ) + } + + const result = safeParseResponse(fetchResponseSchema, output) + if (!result) return null + + if ('results' in result) { + return ( + <BulkPane functionId={functionId} req={req} results={result.results} /> + ) + } + return <SinglePane functionId={functionId} req={req} page={result} /> +} + +/** Compact read-only summary shown while the call sits in the approval gate. */ +export function FetchPreview({ + functionId, + input, +}: { + functionId: string + input: unknown +}) { + const req = safeParseRequest(fetchRequestSchema, input) + if (!req) return null + const urls = targetUrls(req) + return ( + <div className="br-ui-scrape-section is-preview"> + <MetaRow> + <StatusPill + label={ + urls.length > 1 + ? `permission to fetch ${urls.length} urls` + : 'permission to fetch' + } + variant="warn" + /> + <OptionChips functionId={functionId} req={req} /> + </MetaRow> + <TargetLines urls={urls} /> + </div> + ) +} + +function targetUrls(req: FetchRequest): string[] { + if (req.urls?.length) return req.urls + return req.url ? [req.url] : [] +} + +function OptionChips({ + functionId, + req, +}: { + functionId: string + req: FetchRequest +}) { + const method = + functionId === 'browser::fetch' + ? (req.method ?? 'get').toUpperCase() + : null + return ( + <> + <Chip>{fetchEngineLabel(functionId)}</Chip> + {method ? <Chip>{method}</Chip> : null} + {req.impersonate ? ( + <FilterChip label="as" value={req.impersonate} /> + ) : null} + {req.solve_cloudflare ? ( + <Chip className="br-ui-scrape-warning"> + <span>cloudflare</span> + </Chip> + ) : null} + {req.headless === false ? ( + <Chip className="br-ui-scrape-warning"> + <span>headed</span> + </Chip> + ) : null} + {req.real_chrome ? <Chip>real chrome</Chip> : null} + {req.cdp_url ? <Chip>cdp</Chip> : null} + {req.wait_selector ? ( + <FilterChip label="wait" value={req.wait_selector} /> + ) : null} + {req.network_idle ? <Chip>network idle</Chip> : null} + {/* proxy values can embed credentials — flag presence, never the value */} + {req.proxy ? <Chip>proxy</Chip> : null} + {req.selectors?.length ? ( + <FilterChip label="selectors" value={req.selectors.length} /> + ) : null} + {req.format ? <FilterChip label="as" value={req.format} /> : null} + {req.main_content_only ? <Chip>main only</Chip> : null} + {req.include_html ? <Chip>html</Chip> : null} + </> + ) +} + +function TargetLines({ urls }: { urls: string[] }) { + return ( + <> + {urls.slice(0, MAX_TARGET_LINES).map((url, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static wire snapshot; rows never reorder and urls may repeat + <ActionLine key={`${i}:${url}`} symbol="→" tone="ink"> + <span className="br-ui-scrape-break">{url}</span> + </ActionLine> + ))} + {urls.length > MAX_TARGET_LINES ? ( + <div className="br-ui-scrape-more"> + +{urls.length - MAX_TARGET_LINES} more urls + </div> + ) : null} + </> + ) +} + +/* ---------------- single page ---------------- */ + +function SinglePane({ + functionId, + req, + page, +}: { + functionId: string + req: FetchRequest + page: PageResult +}) { + const status = page.status ?? null + const contentType = headerValue(page.headers, 'content-type') + return ( + <div className="br-ui-scrape-section"> + <MetaRow> + <StatusPill + label={status != null ? String(status) : 'done'} + variant={statusToVariant(status)} + /> + <OptionChips functionId={functionId} req={req} /> + {contentType ? ( + <FilterChip label="type" value={contentType.split(';')[0]} /> + ) : null} + {page.captured_xhr?.length ? ( + <FilterChip label="xhr" value={page.captured_xhr.length} /> + ) : null} + </MetaRow> + <ActionLine symbol="→" tone="ink"> + <span className="br-ui-scrape-break">{page.url || req.url || ''}</span> + </ActionLine> + {page.extracted ? <Extracted extracted={page.extracted} /> : null} + {page.content != null ? ( + <RenderedContent format={page.format} content={page.content} /> + ) : null} + {page.html != null ? <HtmlSnippet html={page.html} /> : null} + {!page.extracted && page.content == null && page.html == null ? ( + <div className="br-ui-scrape-empty"> + · page fetched — pass `selectors`, `format`, or `include_html` for + content + </div> + ) : null} + </div> + ) +} + +function headerValue( + headers: Record<string, unknown> | undefined, + name: string, +): string | null { + if (!headers) return null + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === name) return String(value) + } + return null +} + +function statusToVariant( + status: number | null, +): 'accent' | 'default' | 'warn' | 'alert' { + if (status == null) return 'default' + if (status >= 500) return 'alert' + if (status >= 400) return 'warn' + if (status >= 200 && status < 300) return 'accent' + return 'default' +} + +function Extracted({ extracted }: { extracted: Record<string, unknown> }) { + return ( + <div> + <div className="br-ui-scrape-label"> + extracted · {Object.keys(extracted).length} + </div> + <JsonHighlight code={JSON.stringify(extracted, null, 2)} wrap /> + </div> + ) +} + +function RenderedContent({ + format, + content, +}: { + format?: string + content: string +}) { + const truncated = content.length > HTML_PREVIEW_CHARS + return ( + <div> + <div className="br-ui-scrape-label"> + {format ?? 'content'} · {formatChars(content.length)} + {truncated ? ' · truncated' : ''} + </div> + <pre className="br-ui-scrape-pre"> + <code> + {content.slice(0, HTML_PREVIEW_CHARS)} + {truncated ? '…' : ''} + </code> + </pre> + </div> + ) +} + +function HtmlSnippet({ html }: { html: string }) { + const truncated = html.length > HTML_PREVIEW_CHARS + return ( + <div> + <div className="br-ui-scrape-label"> + html · {formatChars(html.length)} + {truncated ? ' · truncated preview' : ''} + </div> + <pre className="br-ui-scrape-pre is-muted"> + <code> + {html.slice(0, HTML_PREVIEW_CHARS)} + {truncated ? '…' : ''} + </code> + </pre> + </div> + ) +} + +/* ---------------- bulk ---------------- */ + +function BulkPane({ + functionId, + req, + results, +}: { + functionId: string + req: FetchRequest + results: PageResult[] +}) { + const failed = results.filter((r) => r.error != null).length + const ok = results.length - failed + const extractedByUrl: Record<string, unknown> = {} + for (const r of results) { + if (r.extracted && r.url) extractedByUrl[r.url] = r.extracted + } + const extractedCount = Object.keys(extractedByUrl).length + return ( + <div className="br-ui-scrape-section"> + <MetaRow> + <StatusPill + label={`${ok}/${results.length} ok`} + variant={failed ? 'warn' : 'accent'} + /> + <OptionChips functionId={functionId} req={req} /> + </MetaRow> + <div> + {results.map((r, i) => ( + <div + // biome-ignore lint/suspicious/noArrayIndexKey: static wire snapshot; rows never reorder and urls may repeat + key={`${i}:${r.url ?? ''}`} + className="br-ui-scrape-result-row" + > + <span + className={cn( + 'br-ui-scrape-row-status', + r.error != null && 'is-warn', + )} + > + {r.error != null ? '✗' : (r.status ?? '·')} + </span> + <span className="br-ui-scrape-row-main br-ui-scrape-break"> + {r.url ?? ''} + </span> + {r.error != null ? ( + <span className="br-ui-scrape-row-error">{r.error}</span> + ) : null} + </div> + ))} + </div> + {extractedCount > 0 ? ( + <div> + <div className="br-ui-scrape-label"> + extracted · {extractedCount} page{extractedCount === 1 ? '' : 's'} + </div> + <JsonHighlight code={JSON.stringify(extractedByUrl, null, 2)} wrap /> + </div> + ) : null} + </div> + ) +} diff --git a/browser/ui/src/function-trigger-message/scrapling/MarkdownView.tsx b/browser/ui/src/function-trigger-message/scrapling/MarkdownView.tsx new file mode 100644 index 000000000..f2385a38d --- /dev/null +++ b/browser/ui/src/function-trigger-message/scrapling/MarkdownView.tsx @@ -0,0 +1,82 @@ +import { Chip, FilterChip, MetaRow, StatusPill } from '../../lib/shared' +import { + formatChars, + markdownRequestSchema, + markdownResponseSchema, + safeParseRequest, + safeParseResponse, +} from './parsers' + +const MAX_PREVIEW_CHARS = 4000 + +export function MarkdownView({ + input, + output, + running, +}: { + input: unknown + output: unknown + running?: boolean +}) { + const req = safeParseRequest(markdownRequestSchema, input) + if (!req) return null + const chips = ( + <> + <Chip>{req.format ?? 'markdown'}</Chip> + {req.css_selector ? ( + <FilterChip label="scope" value={req.css_selector} /> + ) : null} + {req.main_content_only ? <Chip>main only</Chip> : null} + {req.html != null ? ( + <FilterChip label="html in" value={formatChars(req.html.length)} /> + ) : null} + </> + ) + + if (running) { + return ( + <div className="br-ui-scrape-section"> + <MetaRow> + <StatusPill label="converting…" variant="default" /> + {chips} + </MetaRow> + <div className="br-ui-scrape-running"> + · converting… + </div> + </div> + ) + } + + const res = safeParseResponse(markdownResponseSchema, output) + if (!res) return null + const truncated = res.content.length > MAX_PREVIEW_CHARS + return ( + <div className="br-ui-scrape-section"> + <MetaRow> + <StatusPill label={res.format} variant="accent" /> + <Chip> + <span className="br-ui-scrape-num"> + {formatChars(res.content.length)} + </span> + </Chip> + {truncated ? ( + <Chip className="br-ui-scrape-warning"> + <span>truncated</span> + </Chip> + ) : null} + </MetaRow> + {res.content.length === 0 ? ( + <div className="br-ui-scrape-empty"> + · empty + </div> + ) : ( + <pre className="br-ui-scrape-pre"> + <code> + {res.content.slice(0, MAX_PREVIEW_CHARS)} + {truncated ? '…' : ''} + </code> + </pre> + )} + </div> + ) +} diff --git a/browser/ui/src/function-trigger-message/scrapling/ParseViews.tsx b/browser/ui/src/function-trigger-message/scrapling/ParseViews.tsx new file mode 100644 index 000000000..986f94686 --- /dev/null +++ b/browser/ui/src/function-trigger-message/scrapling/ParseViews.tsx @@ -0,0 +1,309 @@ +import { JsonHighlight } from '@iii-dev/console-ui' +import { Chip, FilterChip, MetaRow, StatusPill } from '../../lib/shared' +import { + extractRequestSchema, + extractResponseSchema, + findSimilarRequestSchema, + findSimilarResponseSchema, + formatChars, + queryRequestSchema, + queryResponseSchema, + type SelectorSpec, + safeParseRequest, + safeParseResponse, +} from './parsers' + +const MAX_RESULT_ROWS = 50 +const MAX_SIMILAR_ITEMS = 20 + +/** Smart Element Tracking marker — the match survives site redesigns via saved + * element identities. */ +function AdaptiveChip({ domain }: { domain?: string }) { + return ( + <Chip className="br-ui-scrape-adaptive"> + <span>adaptive</span> + {domain ? ( + <span className="br-ui-scrape-chip-value">{domain}</span> + ) : null} + </Chip> + ) +} + +function RunningNote({ label }: { label: string }) { + return ( + <div className="br-ui-scrape-running"> + · {label} + </div> + ) +} + +function SectionShell({ children }: { children: React.ReactNode }) { + return <div className="br-ui-scrape-section">{children}</div> +} + +/* ---------------- browser::extract ---------------- */ + +function selectorSummary(spec: SelectorSpec): string { + const query = spec.css + ? `css ${spec.css}` + : spec.xpath + ? `xpath ${spec.xpath}` + : spec.regex + ? `re ${spec.regex}` + : '—' + const mods = [ + spec.attr ? `attr=${spec.attr}` : null, + spec.html ? 'html' : null, + spec.all ? 'all' : null, + ] + .filter(Boolean) + .join(' · ') + return mods ? `${query} · ${mods}` : query +} + +function SelectorRows({ selectors }: { selectors: SelectorSpec[] }) { + return ( + <div className="br-ui-scrape-selector-list"> + {selectors.map((spec, i) => ( + <div + // biome-ignore lint/suspicious/noArrayIndexKey: static wire snapshot; specs never reorder and names may repeat + key={`${i}:${spec.name}`} + className="br-ui-scrape-selector-row" + > + <span className="br-ui-scrape-selector-name">{spec.name}</span> + <span className="br-ui-scrape-selector-value"> + ← {selectorSummary(spec)} + </span> + </div> + ))} + </div> + ) +} + +export function ExtractView({ + input, + output, + running, +}: { + input: unknown + output: unknown + running?: boolean +}) { + const req = safeParseRequest(extractRequestSchema, input) + if (!req) return null + const chips = ( + <> + {req.selectors?.length ? ( + <FilterChip label="selectors" value={req.selectors.length} /> + ) : null} + {req.adaptive ? <AdaptiveChip domain={req.adaptive_domain} /> : null} + {req.html != null ? ( + <FilterChip label="html" value={formatChars(req.html.length)} /> + ) : null} + </> + ) + + if (running) { + return ( + <SectionShell> + <MetaRow> + <StatusPill label="extracting…" variant="default" /> + {chips} + </MetaRow> + <RunningNote label="parsing…" /> + </SectionShell> + ) + } + + const res = safeParseResponse(extractResponseSchema, output) + if (!res) return null + const fields = Object.keys(res.extracted).length + return ( + <SectionShell> + <MetaRow> + <StatusPill + label={`${fields} field${fields === 1 ? '' : 's'}`} + variant={fields ? 'accent' : 'warn'} + /> + {chips} + </MetaRow> + {req.selectors?.length ? ( + <SelectorRows selectors={req.selectors} /> + ) : null} + <JsonHighlight code={JSON.stringify(res.extracted, null, 2)} wrap /> + </SectionShell> + ) +} + +/* ---------------- browser::css / xpath / regex ---------------- */ + +export function QueryView({ + functionId, + input, + output, + running, +}: { + functionId: string + input: unknown + output: unknown + running?: boolean +}) { + const req = safeParseRequest(queryRequestSchema, input) + if (!req) return null + const op = functionId.slice('browser::'.length) + const query = op === 'regex' ? req.pattern : req.query + if (query == null) return null + const chips = ( + <> + <FilterChip label={op === 'regex' ? 'pattern' : op} value={query} /> + {req.attr ? <FilterChip label="attr" value={req.attr} /> : null} + {req.first ? <Chip>first</Chip> : null} + {req.adaptive ? <AdaptiveChip domain={req.adaptive_domain} /> : null} + {req.html != null ? ( + <FilterChip label="html" value={formatChars(req.html.length)} /> + ) : null} + </> + ) + + if (running) { + return ( + <SectionShell> + <MetaRow> + <StatusPill label={`${op}…`} variant="default" /> + {chips} + </MetaRow> + <RunningNote label="querying…" /> + </SectionShell> + ) + } + + const res = safeParseResponse(queryResponseSchema, output) + if (!res) return null + const matches = + res.result == null ? 0 : Array.isArray(res.result) ? res.result.length : 1 + return ( + <SectionShell> + <MetaRow> + <StatusPill + label={ + matches === 0 + ? 'no match' + : `${matches} match${matches === 1 ? '' : 'es'}` + } + variant={matches ? 'accent' : 'warn'} + /> + {chips} + </MetaRow> + <ResultRows result={res.result} /> + </SectionShell> + ) +} + +function ResultRows({ result }: { result: string | (string | null)[] | null }) { + if (result == null || (Array.isArray(result) && result.length === 0)) { + return ( + <div className="br-ui-scrape-empty"> + · no match + </div> + ) + } + const numbered = Array.isArray(result) + const rows = numbered ? result : [result] + return ( + <div> + {rows.slice(0, MAX_RESULT_ROWS).map((row, i) => ( + <div + // biome-ignore lint/suspicious/noArrayIndexKey: static wire snapshot; matches never reorder and often repeat + key={`${i}:${row ?? ''}`} + className="br-ui-scrape-result-row" + > + {numbered ? ( + <span className="br-ui-scrape-result-number"> + {i + 1} + </span> + ) : null} + {row == null ? ( + <span className="br-ui-scrape-dim">∅</span> + ) : ( + <span className="br-ui-scrape-row-main br-ui-scrape-prewrap"> + {row} + </span> + )} + </div> + ))} + {rows.length > MAX_RESULT_ROWS ? ( + <div className="br-ui-scrape-more"> + +{rows.length - MAX_RESULT_ROWS} more + </div> + ) : null} + </div> + ) +} + +/* ---------------- browser::find-similar ---------------- */ + +export function FindSimilarView({ + input, + output, + running, +}: { + input: unknown + output: unknown + running?: boolean +}) { + const req = safeParseRequest(findSimilarRequestSchema, input) + if (!req) return null + const chips = ( + <> + {req.anchor ? <FilterChip label="anchor" value={req.anchor} /> : null} + {typeof req.similarity_threshold === 'number' ? ( + <FilterChip label="threshold" value={req.similarity_threshold} /> + ) : null} + {req.match_text ? <Chip>match text</Chip> : null} + {req.html != null ? ( + <FilterChip label="html" value={formatChars(req.html.length)} /> + ) : null} + </> + ) + + if (running) { + return ( + <SectionShell> + <MetaRow> + <StatusPill label="matching…" variant="default" /> + {chips} + </MetaRow> + <RunningNote label="scanning structure…" /> + </SectionShell> + ) + } + + const res = safeParseResponse(findSimilarResponseSchema, output) + if (!res) return null + const shown = res.items.slice(0, MAX_SIMILAR_ITEMS) + return ( + <SectionShell> + <MetaRow> + <StatusPill + label={`${res.count} similar`} + variant={res.count ? 'accent' : 'warn'} + /> + {chips} + </MetaRow> + {res.count === 0 ? ( + <div className="br-ui-scrape-empty"> + · no similar elements + </div> + ) : ( + <> + <JsonHighlight code={JSON.stringify(shown, null, 2)} wrap /> + {res.items.length > MAX_SIMILAR_ITEMS ? ( + <div className="br-ui-scrape-more is-separated"> + +{res.items.length - MAX_SIMILAR_ITEMS} more items + </div> + ) : null} + </> + )} + </SectionShell> + ) +} diff --git a/browser/ui/src/function-trigger-message/scrapling/ScreenshotView.tsx b/browser/ui/src/function-trigger-message/scrapling/ScreenshotView.tsx new file mode 100644 index 000000000..aedcbc34b --- /dev/null +++ b/browser/ui/src/function-trigger-message/scrapling/ScreenshotView.tsx @@ -0,0 +1,114 @@ +import { + ActionLine, + Chip, + MetaRow, + StatusPill, +} from '../../lib/shared' +import { + safeParseRequest, + safeParseResponse, + screenshotRequestSchema, + screenshotResponseSchema, +} from './parsers' + +interface ScreenshotViewProps { + input: unknown + output: unknown + running?: boolean +} + +export function ScreenshotView({ + input, + output, + running, +}: ScreenshotViewProps) { + const req = safeParseRequest(screenshotRequestSchema, input) + if (!req) return null + + if (running) { + return ( + <div className="br-ui-scrape-section"> + <MetaRow> + <StatusPill label="capturing…" variant="default" /> + <Chip>{req.fetcher ?? 'dynamic'}</Chip> + {req.full_page ? <Chip>full page</Chip> : null} + {req.proxy ? <Chip>proxy</Chip> : null} + </MetaRow> + <ActionLine symbol="→" tone="ink"> + <span className="br-ui-scrape-break">{req.url}</span> + </ActionLine> + <div className="br-ui-scrape-running"> + · waiting for the browser… + </div> + </div> + ) + } + + const shot = safeParseResponse(screenshotResponseSchema, output) + if (!shot) return null + + const images = shot.content.filter((b) => b.type === 'image' && b.data) + const caption = shot.content.find((b) => b.type === 'text')?.text + const mime = shot.mime || images[0]?.mime || 'image/png' + const url = shot.url || req.url + const sizeKb = Math.max( + 1, + Math.round( + (images.reduce((n, b) => n + (b.data?.length ?? 0), 0) * 3) / 4 / 1024, + ), + ) + return ( + <div className="br-ui-scrape-section"> + <MetaRow> + <StatusPill label="screenshot" variant="accent" /> + <Chip>{req.fetcher ?? 'dynamic'}</Chip> + <Chip>{mime.replace('image/', '')}</Chip> + {req.full_page ? <Chip>full page</Chip> : null} + {req.proxy ? <Chip>proxy</Chip> : null} + {images.length > 1 ? <Chip>{images.length} tiles</Chip> : null} + <Chip> + <span className="br-ui-scrape-num">{sizeKb}</span> + <span className="br-ui-scrape-unit">KB</span> + </Chip> + </MetaRow> + <ActionLine symbol="→" tone="ink"> + <span className="br-ui-scrape-break">{url}</span> + </ActionLine> + <div className="br-ui-scrape-gallery"> + {images.map((b, i) => ( + <img + key={i} + src={`data:${b.mime || mime};base64,${b.data}`} + alt={caption || `screenshot of ${url || 'page'}`} + loading="lazy" + className="br-ui-scrape-image" + /> + ))} + {caption ? ( + <div className="br-ui-scrape-caption"> + {caption} + </div> + ) : null} + </div> + </div> + ) +} + +export function ScreenshotPreview({ input }: { input: unknown }) { + const req = safeParseRequest(screenshotRequestSchema, input) + if (!req) return null + return ( + <div className="br-ui-scrape-section is-preview"> + <MetaRow> + <StatusPill label="permission to screenshot" variant="warn" /> + <Chip>{req.fetcher ?? 'dynamic'}</Chip> + {req.format ? <Chip>{req.format}</Chip> : null} + {req.full_page ? <Chip>full page</Chip> : null} + {req.proxy ? <Chip>proxy</Chip> : null} + </MetaRow> + <ActionLine symbol="→" tone="ink"> + <span className="br-ui-scrape-break">{req.url}</span> + </ActionLine> + </div> + ) +} diff --git a/browser/ui/src/function-trigger-message/scrapling/SearchViews.tsx b/browser/ui/src/function-trigger-message/scrapling/SearchViews.tsx new file mode 100644 index 000000000..60ddc604d --- /dev/null +++ b/browser/ui/src/function-trigger-message/scrapling/SearchViews.tsx @@ -0,0 +1,246 @@ +import { JsonHighlight } from '@iii-dev/console-ui' +import { Chip, FilterChip, MetaRow, StatusPill } from '../../lib/shared' +import { + describeRequestSchema, + describeResponseSchema, + elementsResponseSchema, + findByRegexRequestSchema, + findByTextRequestSchema, + findRequestSchema, + formatChars, + type ScrapedElement, + safeParseRequest, + safeParseResponse, +} from './parsers' + +const MAX_ROWS = 30 + +function SectionShell({ children }: { children: React.ReactNode }) { + return <div className="br-ui-scrape-section">{children}</div> +} + +function RunningNote({ label }: { label: string }) { + return ( + <div className="br-ui-scrape-running"> + · {label} + </div> + ) +} + +/* ---------------- find / find-by-text / find-by-regex ---------------- */ + +function searchChips(functionId: string, input: unknown): React.ReactNode { + if (functionId === 'browser::find') { + const req = safeParseRequest(findRequestSchema, input) + if (!req) return null + const tag = Array.isArray(req.tag) ? req.tag.join(', ') : req.tag + return ( + <> + {tag ? <FilterChip label="tag" value={tag} /> : null} + {req.attrs + ? Object.entries(req.attrs).map(([k, v]) => ( + <FilterChip key={k} label={k} value={String(v)} /> + )) + : null} + {req.text_regex ? ( + <FilterChip label="text~" value={req.text_regex} /> + ) : null} + {req.html != null ? ( + <FilterChip label="html" value={formatChars(req.html.length)} /> + ) : null} + </> + ) + } + if (functionId === 'browser::find-by-text') { + const req = safeParseRequest(findByTextRequestSchema, input) + if (!req) return null + return ( + <> + {req.text ? <FilterChip label="text" value={req.text} /> : null} + {req.partial ? <Chip>partial</Chip> : null} + {req.case_sensitive ? <Chip>case</Chip> : null} + </> + ) + } + const req = safeParseRequest(findByRegexRequestSchema, input) + if (!req) return null + return ( + <> + {req.pattern ? <FilterChip label="pattern" value={req.pattern} /> : null} + {req.case_sensitive ? <Chip>case</Chip> : null} + </> + ) +} + +export function ElementsView({ + functionId, + input, + output, + running, +}: { + functionId: string + input: unknown + output: unknown + running?: boolean +}) { + const chips = searchChips(functionId, input) + if (chips == null) return null + + if (running) { + return ( + <SectionShell> + <MetaRow> + <StatusPill label="searching…" variant="default" /> + {chips} + </MetaRow> + <RunningNote label="scanning DOM…" /> + </SectionShell> + ) + } + + const res = safeParseResponse(elementsResponseSchema, output) + if (!res) return null + const shown = res.items.slice(0, MAX_ROWS) + return ( + <SectionShell> + <MetaRow> + <StatusPill + label={ + res.count === 0 + ? 'no match' + : `${res.count} element${res.count === 1 ? '' : 's'}` + } + variant={res.count ? 'accent' : 'warn'} + /> + {chips} + </MetaRow> + {res.count === 0 ? ( + <div className="br-ui-scrape-empty"> + · no elements matched + </div> + ) : ( + <> + {shown.map((el, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static wire snapshot; rows never reorder and selectors may repeat + <ElementRow key={`${i}:${el.css ?? ''}`} el={el} /> + ))} + {res.items.length > MAX_ROWS ? ( + <div className="br-ui-scrape-more"> + +{res.items.length - MAX_ROWS} more + </div> + ) : null} + </> + )} + </SectionShell> + ) +} + +function ElementRow({ el }: { el: ScrapedElement }) { + return ( + <div className="br-ui-scrape-element-row"> + <div className="br-ui-scrape-element-main"> + {el.tag ? ( + <span className="br-ui-scrape-element-tag">{el.tag}</span> + ) : null} + <span className="br-ui-scrape-row-main">{el.text || '—'}</span> + </div> + {el.css ? ( + <div className="br-ui-scrape-selector-value"> + {el.css} + </div> + ) : null} + </div> + ) +} + +/* ---------------- describe ---------------- */ + +export function DescribeView({ + input, + output, + running, +}: { + input: unknown + output: unknown + running?: boolean +}) { + const req = safeParseRequest(describeRequestSchema, input) + if (!req) return null + const chips = ( + <FilterChip label={req.kind ?? 'css'} value={req.query ?? ''} /> + ) + + if (running) { + return ( + <SectionShell> + <MetaRow> + <StatusPill label="describing…" variant="default" /> + {chips} + </MetaRow> + <RunningNote label="locating element…" /> + </SectionShell> + ) + } + + const res = safeParseResponse(describeResponseSchema, output) + if (!res) return null + if (!res.found || !res.element) { + return ( + <SectionShell> + <MetaRow> + <StatusPill label="no match" variant="warn" /> + {chips} + </MetaRow> + <div className="br-ui-scrape-empty"> + · element not found + </div> + </SectionShell> + ) + } + + const el = res.element + const rows: Array<[string, string]> = [ + ['tag', el.tag ?? ''], + ['css', el.css ?? ''], + ['full css', el.full_css ?? ''], + ['xpath', el.xpath ?? ''], + ['full xpath', el.full_xpath ?? ''], + ['classes', (el.classes ?? []).join(' ') || '—'], + ['parent', el.parent_tag ?? '—'], + ['children', String(el.children ?? 0)], + ['siblings', String(el.siblings ?? 0)], + ] + return ( + <SectionShell> + <MetaRow> + <StatusPill label={el.tag ?? 'element'} variant="accent" /> + {chips} + </MetaRow> + {el.text ? ( + <div className="br-ui-scrape-description"> + {el.text} + </div> + ) : null} + <div className="br-ui-scrape-table-wrap"> + <table className="br-ui-scrape-table"> + <tbody> + {rows.map(([k, v]) => ( + <tr key={k}> + <td className="br-ui-scrape-table-key">{k}</td> + <td className="br-ui-scrape-table-value">{v}</td> + </tr> + ))} + </tbody> + </table> + </div> + {el.attrs && Object.keys(el.attrs).length > 0 ? ( + <div> + <div className="br-ui-scrape-label is-separated"> + attributes · {Object.keys(el.attrs).length} + </div> + <JsonHighlight code={JSON.stringify(el.attrs, null, 2)} wrap /> + </div> + ) : null} + </SectionShell> + ) +} diff --git a/browser/ui/src/function-trigger-message/scrapling/SessionViews.tsx b/browser/ui/src/function-trigger-message/scrapling/SessionViews.tsx new file mode 100644 index 000000000..44fa74d84 --- /dev/null +++ b/browser/ui/src/function-trigger-message/scrapling/SessionViews.tsx @@ -0,0 +1,354 @@ +import { JsonHighlight } from '@iii-dev/console-ui' +import { + ActionLine, + Chip, + FilterChip, + MetaRow, + StatusPill, +} from '../../lib/shared' +import { + pageResultSchema, + type SessionSummary, + safeParseRequest, + safeParseResponse, + sessionCloseRequestSchema, + sessionCloseResponseSchema, + sessionFetchRequestSchema, + sessionListRequestSchema, + sessionListResponseSchema, + sessionOpenRequestSchema, + sessionOpenResponseSchema, +} from './parsers' + +function shortId(id: string): string { + return id.length > 10 ? `${id.slice(0, 10)}…` : id +} + +function SectionShell({ children }: { children: React.ReactNode }) { + return <div className="br-ui-scrape-section">{children}</div> +} + +/* ---------------- session-open ---------------- */ + +function openChips(input: unknown): React.ReactNode { + const req = safeParseRequest(sessionOpenRequestSchema, input) + if (!req) return null + return ( + <> + <Chip>{req.type ?? 'http'}</Chip> + {req.impersonate ? ( + <FilterChip label="as" value={req.impersonate} /> + ) : null} + {req.solve_cloudflare ? ( + <Chip className="br-ui-scrape-warning"> + <span>cloudflare</span> + </Chip> + ) : null} + {req.real_chrome ? <Chip>real chrome</Chip> : null} + {req.headless === false ? ( + <Chip className="br-ui-scrape-warning"> + <span>headed</span> + </Chip> + ) : null} + {req.proxy ? <Chip>proxy</Chip> : null} + </> + ) +} + +export function SessionOpenView({ + input, + output, + running, +}: { + input: unknown + output: unknown + running?: boolean +}) { + const chips = openChips(input) + if (chips == null) return null + if (running) { + return ( + <SectionShell> + <MetaRow> + <StatusPill label="opening…" variant="default" /> + {chips} + </MetaRow> + <div className="br-ui-scrape-running"> + · starting session… + </div> + </SectionShell> + ) + } + const res = safeParseResponse(sessionOpenResponseSchema, output) + if (!res) return null + return ( + <SectionShell> + <MetaRow> + <StatusPill label="session open" variant="accent" /> + {chips} + </MetaRow> + <ActionLine symbol="#" tone="accent"> + <span className="br-ui-scrape-break">{res.session_id}</span> + </ActionLine> + </SectionShell> + ) +} + +export function SessionOpenPreview({ input }: { input: unknown }) { + const req = safeParseRequest(sessionOpenRequestSchema, input) + if (!req) return null + return ( + <div className="br-ui-scrape-section is-preview"> + <MetaRow> + <StatusPill label="permission to open a session" variant="warn" /> + {openChips(input)} + </MetaRow> + </div> + ) +} + +/* ---------------- session-fetch ---------------- */ + +function fetchHeader(input: unknown): { + sessionId?: string + url?: string + node: React.ReactNode +} | null { + const req = safeParseRequest(sessionFetchRequestSchema, input) + if (!req) return null + return { + sessionId: req?.session_id, + url: req?.url, + node: ( + <> + {req?.session_id ? ( + <FilterChip label="session" value={shortId(req.session_id)} /> + ) : null} + {req?.method ? <Chip>{req.method.toUpperCase()}</Chip> : null} + {req?.selectors?.length ? ( + <FilterChip label="selectors" value={req.selectors.length} /> + ) : null} + {req?.format ? <FilterChip label="as" value={req.format} /> : null} + </> + ), + } +} + +export function SessionFetchView({ + input, + output, + running, +}: { + input: unknown + output: unknown + running?: boolean +}) { + const header = fetchHeader(input) + if (!header) return null + const { url, node } = header + + if (running) { + return ( + <SectionShell> + <MetaRow> + <StatusPill label="fetching…" variant="default" /> + {node} + </MetaRow> + {url ? ( + <ActionLine symbol="→" tone="ink"> + <span className="br-ui-scrape-break">{url}</span> + </ActionLine> + ) : null} + <div className="br-ui-scrape-running"> + · waiting for page… + </div> + </SectionShell> + ) + } + + const page = safeParseResponse(pageResultSchema, output) + if (!page) return null + const status = page.status ?? null + return ( + <SectionShell> + <MetaRow> + <StatusPill + label={status != null ? String(status) : 'done'} + variant={ + status != null && status >= 200 && status < 300 + ? 'accent' + : 'default' + } + /> + {node} + </MetaRow> + <ActionLine symbol="→" tone="ink"> + <span className="br-ui-scrape-break">{page.url || url || ''}</span> + </ActionLine> + {page.extracted ? ( + <div> + <div className="br-ui-scrape-label"> + extracted · {Object.keys(page.extracted).length} + </div> + <JsonHighlight code={JSON.stringify(page.extracted, null, 2)} wrap /> + </div> + ) : null} + {page.content != null ? ( + <pre className="br-ui-scrape-pre is-separated"> + <code>{page.content.slice(0, 2000)}</code> + </pre> + ) : null} + </SectionShell> + ) +} + +export function SessionFetchPreview({ input }: { input: unknown }) { + const header = fetchHeader(input) + if (!header) return null + const { url, node } = header + return ( + <div className="br-ui-scrape-section is-preview"> + <MetaRow> + <StatusPill label="permission to fetch" variant="warn" /> + {node} + </MetaRow> + {url ? ( + <ActionLine symbol="→" tone="ink"> + <span className="br-ui-scrape-break">{url}</span> + </ActionLine> + ) : null} + </div> + ) +} + +/* ---------------- session-close ---------------- */ + +function closeChip(input: unknown): React.ReactNode | null { + const req = safeParseRequest(sessionCloseRequestSchema, input) + if (!req) return null + return <FilterChip label="session" value={shortId(req.session_id)} /> +} + +export function SessionCloseView({ + input, + output, + running, +}: { + input: unknown + output: unknown + running?: boolean +}) { + const chip = closeChip(input) + if (!chip) return null + if (running) { + return ( + <SectionShell> + <MetaRow> + <StatusPill label="closing…" variant="default" /> + {chip} + </MetaRow> + <div className="br-ui-scrape-running">· closing session…</div> + </SectionShell> + ) + } + const res = safeParseResponse(sessionCloseResponseSchema, output) + if (!res) return null + return ( + <SectionShell> + <MetaRow> + <StatusPill + label={res.closed ? 'closed' : 'not found'} + variant={res.closed ? 'accent' : 'warn'} + /> + {chip} + </MetaRow> + </SectionShell> + ) +} + +export function SessionClosePreview({ input }: { input: unknown }) { + const chip = closeChip(input) + if (!chip) return null + return ( + <div className="br-ui-scrape-section is-preview"> + <MetaRow> + <StatusPill label="permission to close a session" variant="warn" /> + {chip} + </MetaRow> + </div> + ) +} + +/* ---------------- session-list ---------------- */ + +export function SessionListView({ + input, + output, + running, +}: { + input: unknown + output: unknown + running?: boolean +}) { + if (!safeParseRequest(sessionListRequestSchema, input)) return null + if (running) { + return ( + <SectionShell> + <MetaRow> + <StatusPill label="listing…" variant="default" /> + </MetaRow> + <div className="br-ui-scrape-running">· listing sessions…</div> + </SectionShell> + ) + } + const res = safeParseResponse(sessionListResponseSchema, output) + if (!res) return null + return ( + <SectionShell> + <MetaRow> + <StatusPill + label={`${res.sessions.length} open`} + variant={res.sessions.length ? 'accent' : 'default'} + /> + </MetaRow> + {res.sessions.length === 0 ? ( + <div className="br-ui-scrape-empty"> + · no open sessions + </div> + ) : ( + <div className="br-ui-scrape-table-wrap"> + <table className="br-ui-scrape-table"> + <tbody> + {res.sessions.map((s) => ( + <SessionRow key={s.session_id} s={s} /> + ))} + </tbody> + </table> + </div> + )} + </SectionShell> + ) +} + +export function SessionListPreview({ input }: { input: unknown }) { + if (!safeParseRequest(sessionListRequestSchema, input)) return null + return ( + <div className="br-ui-scrape-section is-preview"> + <MetaRow> + <StatusPill label="permission to list sessions" variant="warn" /> + </MetaRow> + </div> + ) +} + +function SessionRow({ s }: { s: SessionSummary }) { + return ( + <tr> + <td className="br-ui-scrape-table-type">{s.type ?? 'http'}</td> + <td className="br-ui-scrape-table-value">{s.session_id}</td> + <td className="br-ui-scrape-table-meta"> + {typeof s.idle_s === 'number' ? `idle ${s.idle_s}s` : ''} + </td> + </tr> + ) +} diff --git a/browser/ui/src/function-trigger-message/scrapling/index.test.tsx b/browser/ui/src/function-trigger-message/scrapling/index.test.tsx new file mode 100644 index 000000000..b14e939ee --- /dev/null +++ b/browser/ui/src/function-trigger-message/scrapling/index.test.tsx @@ -0,0 +1,348 @@ +import type { + FunctionTriggerMessage, + FunctionTriggerRenderer, + Host, +} from '@iii-dev/console-ui' +import { describe, expect, it, vi } from 'vitest' +import setup from '../../../page' +import { createScraplingRenderer } from './index' + +vi.mock('@iii-dev/console-ui', () => ({ + Badge: () => null, + Button: () => null, + Input: () => null, + JsonHighlight: () => null, + StatusDot: () => null, +})) + +const host = {} as Host + +describe('scraping renderer', () => { + it('owns URL screenshot but not interactive session screenshot', () => { + const renderer = createScraplingRenderer(host) + expect(renderer.isMatch('browser::screenshot-url')).toBe(true) + expect(renderer.isMatch('browser::screenshot')).toBe(false) + }) + + it('renders approval previews and parse results', () => { + const renderer = createScraplingRenderer(host) + const preview = { + functionId: 'browser::fetch', + input: { url: 'https://example.com' }, + pendingApproval: true, + } as FunctionTriggerMessage + const result = { + functionId: 'browser::css', + input: { html: '<p>x</p>', query: 'p' }, + output: { result: ['x'] }, + } as FunctionTriggerMessage + expect(renderer.tryRenderPreview?.(preview)).not.toBeNull() + expect(renderer.tryRender(result)).not.toBeNull() + }) + + it('redacts proxy-bearing values throughout raw payloads', () => { + const renderer = createScraplingRenderer(host) + const input = { + proxy: 'http://user:pass@example.com', + nested: [ + { proxies: { https: 'http://token@example.com' } }, + { proxy_auth: { username: 'user', password: 'pass' } }, + { proxying: 'unchanged' }, + ], + } + expect(renderer.redactRaw?.(input)).toEqual({ + proxy: '[redacted]', + nested: [ + { proxies: '[redacted]' }, + { proxy_auth: '[redacted]' }, + { proxying: 'unchanged' }, + ], + }) + expect(input.proxy).toBe('http://user:pass@example.com') + }) + + it('marks circular raw payloads without mutating them', () => { + const renderer = createScraplingRenderer(host) + const input: Record<string, unknown> = { proxy: 'secret' } + input.self = input + expect(renderer.redactRaw?.(input)).toEqual({ + proxy: '[redacted]', + self: '[circular]', + }) + expect(input.self).toBe(input) + }) + + it('redacts proxy URL userinfo embedded in raw strings', () => { + const renderer = createScraplingRenderer(host) + const error = + 'proxy is not usable: http://user:pass@proxy.example: invalid endpoint' + const redacted = + 'proxy is not usable: http://[redacted]@proxy.example: invalid endpoint' + expect(renderer.redactRaw?.(error)).toBe(redacted) + expect(renderer.redactRaw?.({ nested: { error } })).toEqual({ + nested: { error: redacted }, + }) + expect(renderer.redactRaw?.('request failed for https://example.com/a')).toBe( + 'request failed for https://example.com/a', + ) + }) + + it('redacts proxy credentials in rich error cards', () => { + const renderer = createScraplingRenderer(host) + const rendered = renderer.tryRender({ + functionId: 'browser::fetch', + input: { url: 'https://example.com' }, + output: { + error: { + kind: 'function_error', + message: + 'proxy failed: http://user:pass@proxy.example is unavailable', + }, + }, + } as FunctionTriggerMessage) + const serialized = JSON.stringify(rendered) + expect(serialized).toContain('[redacted]') + expect(serialized).not.toContain('user:pass') + }) + + it('falls back from empty urls to the single fetch url', () => { + const renderer = createScraplingRenderer(host) + expect( + renderer.tryRender({ + functionId: 'browser::fetch', + input: { url: 'https://example.com', urls: [] }, + output: { status: 200, url: 'https://example.com' }, + } as FunctionTriggerMessage), + ).not.toBeNull() + expect( + renderer.tryRender({ + functionId: 'browser::fetch', + input: { urls: [] }, + output: { status: 200, url: 'https://example.com' }, + } as FunctionTriggerMessage), + ).toBeNull() + }) + + it('renders approval and running states for close and list sessions', () => { + const renderer = createScraplingRenderer(host) + const sessionId = '1234567890abcdef' + for (const [functionId, input] of [ + ['browser::session-close', { session_id: sessionId }], + ['browser::session-list', {}], + ] as const) { + const preview = renderer.tryRenderPreview?.({ + functionId, + input, + pendingApproval: true, + } as FunctionTriggerMessage) + const running = renderer.tryRenderRunning?.({ + functionId, + input, + running: true, + } as FunctionTriggerMessage) + expect(preview).not.toBeNull() + expect(running).not.toBeNull() + if (functionId === 'browser::session-close') { + for (const rendered of [preview, running]) { + const serialized = JSON.stringify(rendered) + expect(serialized).toContain('1234567890…') + expect(serialized).not.toContain(sessionId) + } + } + } + }) + + it('validates terminal close and list session requests', () => { + const renderer = createScraplingRenderer(host) + for (const [functionId, input, output] of [ + ['browser::session-close', { session_id: 'session-1' }, { closed: true }], + ['browser::session-list', { type: 'http' }, { sessions: [] }], + ] as const) { + expect( + renderer.tryRender({ + functionId, + input, + output, + } as FunctionTriggerMessage), + ).not.toBeNull() + } + + for (const [functionId, input, output] of [ + ['browser::session-close', {}, { closed: true }], + ['browser::session-list', { type: 42 }, { sessions: [] }], + ] as const) { + expect( + renderer.tryRender({ + functionId, + input, + output, + } as unknown as FunctionTriggerMessage), + ).toBeNull() + } + }) + + it.each([ + ['browser::extract', { html: '<p>x</p>' }, { extracted: {} }], + [ + 'browser::find-similar', + { html: '<p>x</p>' }, + { count: 0, items: [] }, + ], + ['browser::find', {}, { count: 0, items: [] }], + [ + 'browser::find-by-text', + { html: '<p>x</p>' }, + { count: 0, items: [] }, + ], + [ + 'browser::find-by-regex', + { html: '<p>x</p>' }, + { count: 0, items: [] }, + ], + ['browser::describe', { html: '<p>x</p>' }, { found: false }], + ['browser::to-markdown', {}, { format: 'markdown', content: '' }], + [ + 'browser::session-fetch', + { session_id: 'session-1' }, + { status: 200, url: 'https://example.com' }, + ], + [ + 'browser::crawl', + {}, + { stats: { crawled: 0, items: 0, errors: 0 } }, + ], + ])('falls through %s requests missing required fields or targets', (functionId, input, output) => { + const renderer = createScraplingRenderer(host) + expect( + renderer.tryRender({ + functionId, + input, + output, + } as FunctionTriggerMessage), + ).toBeNull() + }) + + it('falls through empty fetch payloads', () => { + const renderer = createScraplingRenderer(host) + expect( + renderer.tryRender({ + functionId: 'browser::fetch', + input: {}, + output: {}, + } as FunctionTriggerMessage), + ).toBeNull() + }) + + it('falls through CSS requests missing required input', () => { + const renderer = createScraplingRenderer(host) + expect( + renderer.tryRender({ + functionId: 'browser::css', + input: { html: '<p>x</p>' }, + output: { result: ['x'] }, + } as FunctionTriggerMessage), + ).toBeNull() + expect( + renderer.tryRender({ + functionId: 'browser::css', + input: { query: 'p' }, + output: { result: ['x'] }, + } as FunctionTriggerMessage), + ).toBeNull() + }) + + it('falls through screenshots with an invalid request', () => { + const renderer = createScraplingRenderer(host) + expect( + renderer.tryRender({ + functionId: 'browser::screenshot-url', + input: { url: 42 }, + output: { + content: [{ type: 'image', mime: 'image/png', data: 'aGk=' }], + url: 'https://example.com', + }, + } as unknown as FunctionTriggerMessage), + ).toBeNull() + }) + + it('shows only proxy presence in screenshot states', () => { + const renderer = createScraplingRenderer(host) + const input = { + url: 'https://example.com', + proxy: 'http://user:pass@proxy.example', + } + const states = [ + renderer.tryRenderPreview?.({ + functionId: 'browser::screenshot-url', + input, + pendingApproval: true, + } as FunctionTriggerMessage), + renderer.tryRenderRunning?.({ + functionId: 'browser::screenshot-url', + input, + running: true, + } as FunctionTriggerMessage), + renderer.tryRender({ + functionId: 'browser::screenshot-url', + input, + output: { + content: [{ type: 'image', mime: 'image/png', data: 'aGk=' }], + url: 'https://example.com', + }, + } as FunctionTriggerMessage), + ] + for (const state of states) { + const serialized = JSON.stringify(state) + expect(serialized).toContain('"proxy"') + expect(serialized).not.toContain('user:pass') + expect(serialized).not.toContain('proxy.example') + } + }) + + it('falls through screenshot successes without image data', () => { + const renderer = createScraplingRenderer(host) + for (const content of [ + [], + [{ type: 'text', text: 'Screenshot captured' }], + [{ type: 'image', mime: 'image/png', data: '' }], + ]) { + expect( + renderer.tryRender({ + functionId: 'browser::screenshot-url', + input: { url: 'https://example.com' }, + output: { content, url: 'https://example.com' }, + } as FunctionTriggerMessage), + ).toBeNull() + } + }) + + it('falls through malformed result payloads', () => { + const renderer = createScraplingRenderer(host) + expect( + renderer.tryRender({ + functionId: 'browser::css', + input: { html: '<p>x</p>', query: 'p' }, + output: { result: 42 }, + } as unknown as FunctionTriggerMessage), + ).toBeNull() + }) + + it('registers the exact scraping renderer before the broad browser renderer', () => { + const renderers: FunctionTriggerRenderer[] = [] + setup({ + pages: { register: () => () => {} }, + configForms: { register: () => () => {} }, + functionTriggers: { + register: (renderer: FunctionTriggerRenderer) => { + renderers.push(renderer) + return () => {} + }, + }, + } as unknown as Host) + expect(renderers.map((renderer) => renderer.id)).toEqual([ + 'browser/page.js#screenshot-display', + 'browser/page.js#scraping-calls', + 'browser/page.js#calls', + ]) + }) +}) diff --git a/browser/ui/src/function-trigger-message/scrapling/index.tsx b/browser/ui/src/function-trigger-message/scrapling/index.tsx new file mode 100644 index 000000000..0848ac992 --- /dev/null +++ b/browser/ui/src/function-trigger-message/scrapling/index.tsx @@ -0,0 +1,166 @@ +import type { + FunctionTriggerMessage, + FunctionTriggerRenderer, + Host, +} from '@iii-dev/console-ui' +import { InfraErrorView, parseInfraErrorDisplay } from '../../lib/errors' +import { FunctionIdLabel } from '..' +import { CrawlPreview, CrawlView } from './CrawlView' +import { FetchPreview, FetchView } from './FetchView' +import { MarkdownView } from './MarkdownView' +import { ExtractView, FindSimilarView, QueryView } from './ParseViews' +import { + ELEMENT_SEARCH_FUNCTION_IDS, + FETCH_FUNCTION_IDS, + isScraplingFunction, + SESSION_GATED_FUNCTION_IDS, + unwrapEnvelope, +} from './parsers' +import { ScreenshotPreview, ScreenshotView } from './ScreenshotView' +import { DescribeView, ElementsView } from './SearchViews' +import { + SessionClosePreview, + SessionCloseView, + SessionFetchPreview, + SessionFetchView, + SessionListPreview, + SessionListView, + SessionOpenPreview, + SessionOpenView, +} from './SessionViews' + +const PROXY_KEYS = new Set(['proxy', 'proxies', 'proxy_auth']) +const PROXY_URL_USERINFO = /(https?:\/\/)[^\s/?#]*@/gi + +function redactProxyValues( + value: unknown, + seen: WeakSet<object> = new WeakSet(), +): unknown { + if (typeof value === 'string') { + return value.replace(PROXY_URL_USERINFO, '$1[redacted]@') + } + if (value === null || typeof value !== 'object') return value + if (seen.has(value)) return '[circular]' + seen.add(value) + try { + if (Array.isArray(value)) { + return value.map((entry) => redactProxyValues(entry, seen)) + } + return Object.fromEntries( + Object.entries(value as Record<string, unknown>).map(([key, entry]) => [ + key, + PROXY_KEYS.has(key) ? '[redacted]' : redactProxyValues(entry, seen), + ]), + ) + } catch { + return '[redacted]' + } finally { + seen.delete(value) + } +} + +function tryRender(message: FunctionTriggerMessage): React.ReactNode | null { + if (!isScraplingFunction(message.functionId) || message.pendingApproval) { + return null + } + + const running = !!message.running + const rawOutput = message.output + const errorDisplay = + !running && rawOutput != null + ? parseInfraErrorDisplay(redactProxyValues(rawOutput)) + : null + if (errorDisplay) return <InfraErrorView display={errorDisplay} /> + + const input = unwrapEnvelope(message.input) + const output = rawOutput != null ? unwrapEnvelope(rawOutput) : undefined + + if (FETCH_FUNCTION_IDS.has(message.functionId)) { + return FetchView({ + functionId: message.functionId, + input, + output, + running, + }) + } + if (ELEMENT_SEARCH_FUNCTION_IDS.has(message.functionId)) { + return ElementsView({ + functionId: message.functionId, + input, + output, + running, + }) + } + switch (message.functionId) { + case 'browser::screenshot-url': + return ScreenshotView({ input, output, running }) + case 'browser::extract': + return ExtractView({ input, output, running }) + case 'browser::css': + case 'browser::xpath': + case 'browser::regex': + return QueryView({ + functionId: message.functionId, + input, + output, + running, + }) + case 'browser::find-similar': + return FindSimilarView({ input, output, running }) + case 'browser::describe': + return DescribeView({ input, output, running }) + case 'browser::to-markdown': + return MarkdownView({ input, output, running }) + case 'browser::session-open': + return SessionOpenView({ input, output, running }) + case 'browser::session-fetch': + return SessionFetchView({ input, output, running }) + case 'browser::session-close': + return SessionCloseView({ input, output, running }) + case 'browser::session-list': + return SessionListView({ input, output, running }) + case 'browser::crawl': + return CrawlView({ input, output, running }) + } +} + +function tryRenderPreview( + message: FunctionTriggerMessage, +): React.ReactNode | null { + if (!isScraplingFunction(message.functionId)) return null + const input = unwrapEnvelope(message.input) + if (FETCH_FUNCTION_IDS.has(message.functionId)) { + return FetchPreview({ functionId: message.functionId, input }) + } + if (message.functionId === 'browser::screenshot-url') { + return ScreenshotPreview({ input }) + } + if (SESSION_GATED_FUNCTION_IDS.has(message.functionId)) { + switch (message.functionId) { + case 'browser::session-open': + return SessionOpenPreview({ input }) + case 'browser::session-fetch': + return SessionFetchPreview({ input }) + case 'browser::session-close': + return SessionClosePreview({ input }) + case 'browser::session-list': + return SessionListPreview({ input }) + } + } + if (message.functionId === 'browser::crawl') return CrawlPreview({ input }) + return null +} + +export function createScraplingRenderer( + _host: Host, +): FunctionTriggerRenderer { + return { + id: 'browser/page.js#scraping-calls', + isMatch: isScraplingFunction, + tryRender, + tryRenderRunning: tryRender, + tryRenderPreview, + FunctionIdLabel, + redactRaw: redactProxyValues, + } +} diff --git a/browser/ui/src/function-trigger-message/scrapling/parsers.test.ts b/browser/ui/src/function-trigger-message/scrapling/parsers.test.ts new file mode 100644 index 000000000..89bff7618 --- /dev/null +++ b/browser/ui/src/function-trigger-message/scrapling/parsers.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { + fetchEngineLabel, + isScraplingFunction, + safeParseResponse, + screenshotResponseSchema, + SCRAPLING_FUNCTION_IDS, +} from './parsers' + +describe('root browser scraping ids', () => { + it('claims all 19 scraping functions without stealing session screenshot', () => { + expect(SCRAPLING_FUNCTION_IDS).toEqual([ + 'browser::fetch', + 'browser::stealthy-fetch', + 'browser::dynamic-fetch', + 'browser::screenshot-url', + 'browser::extract', + 'browser::css', + 'browser::xpath', + 'browser::regex', + 'browser::find-similar', + 'browser::find', + 'browser::find-by-text', + 'browser::find-by-regex', + 'browser::describe', + 'browser::to-markdown', + 'browser::session-open', + 'browser::session-fetch', + 'browser::session-close', + 'browser::session-list', + 'browser::crawl', + ]) + expect(isScraplingFunction('browser::screenshot-url')).toBe(true) + expect(isScraplingFunction('browser::screenshot')).toBe(false) + expect(isScraplingFunction('browser::scrapling::fetch')).toBe(false) + expect(fetchEngineLabel('browser::stealthy-fetch')).toBe('stealth') + }) + + it('decodes native screenshot tiles from a harness envelope', () => { + const parsed = safeParseResponse(screenshotResponseSchema, { + content: [{ type: 'text', text: 'caption' }], + details: { + content: [{ type: 'image', mime: 'image/png', data: 'aGk=' }], + mime: 'image/png', + url: 'https://example.com', + }, + }) + expect(parsed?.content[0]?.data).toBe('aGk=') + }) +}) diff --git a/browser/ui/src/function-trigger-message/scrapling/parsers.ts b/browser/ui/src/function-trigger-message/scrapling/parsers.ts new file mode 100644 index 000000000..3b2e5ea98 --- /dev/null +++ b/browser/ui/src/function-trigger-message/scrapling/parsers.ts @@ -0,0 +1,448 @@ +/** + * Zod schemas + helpers for the browser scraping function family. + * + * Wire source: + * workers/scrapling/src/schemas.py -> FUNCTIONS (request/response schemas) + * workers/scrapling/src/core.py -> serialize_page / op_* return shapes + * + * Fetch responses (fetch / stealthy-fetch / dynamic-fetch) are one of: + * single page { status, url, headers, cookies, encoding, extracted?, + * captured_xhr?, html? } + * bulk { results: [ page | { url, error } ] } (when called with `urls`) + * + * Schemas are non-strict so additive wire fields don't break the UI. + */ +import { z } from 'zod' +import { decodeBrowserResult } from '../../lib/browser' + +export const unwrapEnvelope = decodeBrowserResult + +export const SCRAPLING_FUNCTION_IDS = [ + 'browser::fetch', + 'browser::stealthy-fetch', + 'browser::dynamic-fetch', + 'browser::screenshot-url', + 'browser::extract', + 'browser::css', + 'browser::xpath', + 'browser::regex', + 'browser::find-similar', + 'browser::find', + 'browser::find-by-text', + 'browser::find-by-regex', + 'browser::describe', + 'browser::to-markdown', + 'browser::session-open', + 'browser::session-fetch', + 'browser::session-close', + 'browser::session-list', + 'browser::crawl', +] as const +export type ScraplingFunctionId = (typeof SCRAPLING_FUNCTION_IDS)[number] + +/** find / find-by-text / find-by-regex all return `{count, items:[element]}`. */ +export const ELEMENT_SEARCH_FUNCTION_IDS: ReadonlySet<string> = new Set([ + 'browser::find', + 'browser::find-by-text', + 'browser::find-by-regex', +]) + +/** Session operations require approval in the browser worker permissions. */ +export const SESSION_GATED_FUNCTION_IDS: ReadonlySet<string> = new Set([ + 'browser::session-open', + 'browser::session-fetch', + 'browser::session-close', + 'browser::session-list', +]) + +const SCRAPLING_FUNCTION_ID_SET: ReadonlySet<string> = new Set<string>( + SCRAPLING_FUNCTION_IDS, +) + +export function isScraplingFunction(id: string): id is ScraplingFunctionId { + return SCRAPLING_FUNCTION_ID_SET.has(id) +} + +export const FETCH_FUNCTION_IDS: ReadonlySet<string> = new Set([ + 'browser::fetch', + 'browser::stealthy-fetch', + 'browser::dynamic-fetch', +]) + +/** Which fetch tier a function runs on — drives the engine chip. */ +export function fetchEngineLabel(functionId: string): string { + switch (functionId) { + case 'browser::stealthy-fetch': + return 'stealth' + case 'browser::dynamic-fetch': + return 'chromium' + default: + return 'http' + } +} + +/* ---------------- selectors (shared by fetch + parse ops) ---------------- */ + +export const selectorSpecSchema = z.object({ + name: z.string(), + css: z.string().optional(), + xpath: z.string().optional(), + regex: z.string().optional(), + attr: z.string().optional(), + html: z.boolean().optional(), + all: z.boolean().optional(), +}) +export type SelectorSpec = z.infer<typeof selectorSpecSchema> + +/* ---------------- fetch / stealthy-fetch / dynamic-fetch ---------------- */ + +export const fetchRequestSchema = z.object({ + url: z.string().min(1).optional(), + urls: z.array(z.string().min(1)).optional(), + method: z.string().optional(), + impersonate: z.string().optional(), + proxy: z.string().optional(), + headless: z.boolean().optional(), + network_idle: z.boolean().optional(), + solve_cloudflare: z.boolean().optional(), + real_chrome: z.boolean().optional(), + cdp_url: z.string().optional(), + wait_selector: z.string().optional(), + timeout: z.number().optional(), + selectors: z.array(selectorSpecSchema).optional(), + include_html: z.boolean().optional(), + format: z.string().optional(), + main_content_only: z.boolean().optional(), +}).refine( + (request) => + (request.url?.length ?? 0) > 0 || (request.urls?.length ?? 0) > 0, +) +export type FetchRequest = z.infer<typeof fetchRequestSchema> + +export const pageResultSchema = z.object({ + status: z.number().nullable().optional(), + url: z.string().optional(), + headers: z.record(z.string(), z.unknown()).optional(), + cookies: z.record(z.string(), z.unknown()).optional(), + encoding: z.string().nullable().optional(), + extracted: z.record(z.string(), z.unknown()).optional(), + captured_xhr: z.array(z.unknown()).optional(), + html: z.string().optional(), + /** markdown/text render when `format` was requested */ + content: z.string().optional(), + format: z.string().optional(), + /** bulk per-url failure rows are `{ url, error }` */ + error: z.string().optional(), +}).refine((result) => Object.keys(result).length > 0) +export type PageResult = z.infer<typeof pageResultSchema> + +export const bulkResponseSchema = z.object({ + results: z.array(pageResultSchema), +}) +export type BulkResponse = z.infer<typeof bulkResponseSchema> + +/** Bulk first: the loose page schema would match (and strip) a bulk payload. */ +export const fetchResponseSchema = z.union([ + bulkResponseSchema, + pageResultSchema, +]) +export type FetchResponse = z.infer<typeof fetchResponseSchema> + +/* ---------------- screenshot ---------------- */ + +export const screenshotRequestSchema = z.object({ + url: z.string(), + fetcher: z.string().optional(), + proxy: z.string().optional(), + full_page: z.boolean().optional(), + format: z.string().optional(), +}) +export type ScreenshotRequest = z.infer<typeof screenshotRequestSchema> + +/** Harness content blocks: image tiles + a trailing text caption. */ +const screenshotBlockSchema = z.object({ + type: z.string(), + mime: z.string().optional(), + data: z.string().optional(), + text: z.string().optional(), +}) + +export const screenshotResponseSchema = z + .object({ + content: z.array(screenshotBlockSchema), + mime: z.string().optional(), + url: z.string().optional(), + }) + .refine((response) => + response.content.some( + (block) => block.type === 'image' && (block.data?.length ?? 0) > 0, + ), + ) +export type ScreenshotResponse = z.infer<typeof screenshotResponseSchema> + +/* ---------------- parse-only ops ---------------- */ + +export const extractRequestSchema = z.object({ + html: z.string(), + selectors: z.array(selectorSpecSchema), + adaptive: z.boolean().optional(), + adaptive_domain: z.string().optional(), +}) +export type ExtractRequest = z.infer<typeof extractRequestSchema> + +export const extractResponseSchema = z.object({ + extracted: z.record(z.string(), z.unknown()), +}) +export type ExtractResponse = z.infer<typeof extractResponseSchema> + +export const queryRequestSchema = z.object({ + html: z.string(), + query: z.string().optional(), + pattern: z.string().optional(), + first: z.boolean().optional(), + attr: z.string().optional(), + adaptive: z.boolean().optional(), + identifier: z.string().optional(), + adaptive_domain: z.string().optional(), +}) +export type QueryRequest = z.infer<typeof queryRequestSchema> + +/** `attr` misses inside an `all` list come back as null items. */ +export const queryResponseSchema = z.object({ + result: z.union([z.array(z.string().nullable()), z.string(), z.null()]), +}) +export type QueryResponse = z.infer<typeof queryResponseSchema> + +export const findSimilarRequestSchema = z.object({ + html: z.string(), + anchor: z.string(), + similarity_threshold: z.number().optional(), + match_text: z.boolean().optional(), + selectors: z.array(selectorSpecSchema).optional(), +}) +export type FindSimilarRequest = z.infer<typeof findSimilarRequestSchema> + +export const findSimilarResponseSchema = z.object({ + count: z.number(), + items: z.array(z.record(z.string(), z.unknown())), +}) +export type FindSimilarResponse = z.infer<typeof findSimilarResponseSchema> + +/* ---------------- element search (find / find-by-text / find-by-regex) ---- */ + +export const elementSchema = z.object({ + tag: z.string().optional(), + text: z.string().optional(), + html: z.string().optional(), + attrs: z.record(z.string(), z.unknown()).optional(), + css: z.string().optional(), + xpath: z.string().optional(), +}) +export type ScrapedElement = z.infer<typeof elementSchema> + +export const elementsResponseSchema = z.object({ + count: z.number(), + items: z.array(elementSchema), +}) +export type ElementsResponse = z.infer<typeof elementsResponseSchema> + +export const findRequestSchema = z.object({ + html: z.string(), + tag: z.union([z.string(), z.array(z.string())]).optional(), + attrs: z.record(z.string(), z.unknown()).optional(), + text_regex: z.string().optional(), + first: z.boolean().optional(), + limit: z.number().optional(), +}) +export type FindRequest = z.infer<typeof findRequestSchema> + +export const findByTextRequestSchema = z.object({ + html: z.string(), + text: z.string(), + partial: z.boolean().optional(), + case_sensitive: z.boolean().optional(), + first: z.boolean().optional(), +}) +export type FindByTextRequest = z.infer<typeof findByTextRequestSchema> + +export const findByRegexRequestSchema = z.object({ + html: z.string(), + pattern: z.string(), + case_sensitive: z.boolean().optional(), + first: z.boolean().optional(), +}) +export type FindByRegexRequest = z.infer<typeof findByRegexRequestSchema> + +/* ---------------- describe ---------------- */ + +export const describeRequestSchema = z.object({ + html: z.string(), + query: z.string(), + kind: z.string().optional(), +}) +export type DescribeRequest = z.infer<typeof describeRequestSchema> + +export const describeElementSchema = elementSchema.extend({ + full_css: z.string().optional(), + full_xpath: z.string().optional(), + classes: z.array(z.string()).optional(), + parent_tag: z.string().nullable().optional(), + children: z.number().optional(), + siblings: z.number().optional(), +}) +export type DescribeElement = z.infer<typeof describeElementSchema> + +export const describeResponseSchema = z.object({ + found: z.boolean(), + element: describeElementSchema.optional(), +}) +export type DescribeResponse = z.infer<typeof describeResponseSchema> + +/* ---------------- to-markdown ---------------- */ + +export const markdownRequestSchema = z.object({ + html: z.string(), + format: z.string().optional(), + css_selector: z.string().optional(), + main_content_only: z.boolean().optional(), +}) +export type MarkdownRequest = z.infer<typeof markdownRequestSchema> + +export const markdownResponseSchema = z.object({ + format: z.string(), + content: z.string(), +}) +export type MarkdownResponse = z.infer<typeof markdownResponseSchema> + +/* ---------------- sessions ---------------- */ + +export const sessionOpenRequestSchema = z.object({ + type: z.string().optional(), + impersonate: z.string().optional(), + proxy: z.string().optional(), + headless: z.boolean().optional(), + useragent: z.string().optional(), + solve_cloudflare: z.boolean().optional(), + real_chrome: z.boolean().optional(), +}) +export type SessionOpenRequest = z.infer<typeof sessionOpenRequestSchema> + +export const sessionOpenResponseSchema = z.object({ + session_id: z.string(), + type: z.string().optional(), +}) +export type SessionOpenResponse = z.infer<typeof sessionOpenResponseSchema> + +export const sessionFetchRequestSchema = z.object({ + session_id: z.string(), + url: z.string(), + method: z.string().optional(), + selectors: z.array(selectorSpecSchema).optional(), + format: z.string().optional(), + include_html: z.boolean().optional(), +}) +export type SessionFetchRequest = z.infer<typeof sessionFetchRequestSchema> + +export const sessionCloseResponseSchema = z.object({ + closed: z.boolean(), +}) +export type SessionCloseResponse = z.infer<typeof sessionCloseResponseSchema> + +export const sessionCloseRequestSchema = z.object({ + session_id: z.string(), +}) +export type SessionCloseRequest = z.infer<typeof sessionCloseRequestSchema> + +export const sessionSummarySchema = z.object({ + session_id: z.string(), + type: z.string().optional(), + created_at: z.number().optional(), + last_used: z.number().optional(), + idle_s: z.number().optional(), +}) +export type SessionSummary = z.infer<typeof sessionSummarySchema> + +export const sessionListResponseSchema = z.object({ + sessions: z.array(sessionSummarySchema), +}) +export type SessionListResponse = z.infer<typeof sessionListResponseSchema> + +export const sessionListRequestSchema = z.object({ + type: z.string().optional(), +}) +export type SessionListRequest = z.infer<typeof sessionListRequestSchema> + +/* ---------------- crawl ---------------- */ + +export const crawlRequestSchema = z + .object({ + start_urls: z.array(z.string().min(1)).optional(), + url: z.string().min(1).optional(), + fetcher: z.string().optional(), + selectors: z.array(selectorSpecSchema).optional(), + allowed_domains: z.array(z.string()).optional(), + same_domain: z.boolean().optional(), + max_pages: z.number().optional(), + max_depth: z.number().optional(), + concurrency: z.number().optional(), + format: z.string().optional(), + stream_name: z.string().optional(), + }) + .refine( + (request) => + (request.url?.length ?? 0) > 0 || + (request.start_urls?.length ?? 0) > 0, + ) +export type CrawlRequest = z.infer<typeof crawlRequestSchema> + +export const crawlItemSchema = z.object({ + url: z.string().optional(), + status: z.number().nullable().optional(), + extracted: z.record(z.string(), z.unknown()).optional(), + content: z.string().optional(), + error: z.string().optional(), +}) +export type CrawlItem = z.infer<typeof crawlItemSchema> + +export const crawlResponseSchema = z.object({ + stats: z.object({ + crawled: z.number(), + items: z.number(), + errors: z.number(), + stopped: z.string().optional(), + }), + items: z.array(crawlItemSchema).optional(), + // The worker echoes the caller's stream_name/group_id verbatim, so either + // may arrive as a number or boolean (e.g. group_id:3) — accept those, not + // just strings, or the whole card fails to parse and renders nothing. + stream: z + .object({ + name: z.union([z.string(), z.number(), z.boolean()]).optional(), + group_id: z.union([z.string(), z.number(), z.boolean()]).optional(), + }) + .optional(), +}) +export type CrawlResponse = z.infer<typeof crawlResponseSchema> + +/* ---------------- helpers ---------------- */ + +export function safeParseRequest<T>( + schema: z.ZodType<T>, + value: unknown, +): T | null { + const parsed = schema.safeParse(value ?? {}) + return parsed.success ? parsed.data : null +} + +export function safeParseResponse<T>( + schema: z.ZodType<T>, + value: unknown, +): T | null { + const parsed = schema.safeParse(unwrapEnvelope(value)) + return parsed.success ? parsed.data : null +} + +export function formatChars(n: number): string { + if (n < 1000) return `${n} chars` + return `${(n / 1000).toFixed(1)}k chars` +} diff --git a/browser/ui/src/lib/shared.tsx b/browser/ui/src/lib/shared.tsx index 5e5b6e80e..3d42c59f6 100644 --- a/browser/ui/src/lib/shared.tsx +++ b/browser/ui/src/lib/shared.tsx @@ -19,6 +19,21 @@ export function Chip({ return <span className={cn('br-ui-chip', className)}>{children}</span> } +export function FilterChip({ + label, + value, +}: { + label: string + value: ReactNode +}) { + return ( + <Chip> + <span className="br-ui-chip-label">{label}</span> + <span>{value}</span> + </Chip> + ) +} + export function MetaRow({ children }: { children: ReactNode }) { return <div className="br-ui-meta-row">{children}</div> } diff --git a/browser/ui/styles.css b/browser/ui/styles.css index 8d02bdacc..927d183b8 100644 --- a/browser/ui/styles.css +++ b/browser/ui/styles.css @@ -2032,6 +2032,9 @@ } [data-iii-ui="browser"] .br-ui-chip { display: inline-flex; + box-sizing: border-box; + min-width: 0; + max-width: 100%; align-items: center; border: 1px solid var(--color-rule-2); background: var(--color-paper-2); @@ -2039,6 +2042,13 @@ padding: 2px 6px; font-size: 10px; color: var(--color-ink-faint); + white-space: normal; + overflow-wrap: anywhere; +} +[data-iii-ui="browser"] .br-ui-chip > span:last-child { + min-width: 0; + white-space: normal; + overflow-wrap: anywhere; } [data-iii-ui="browser"] .br-ui-chip-accent { color: var(--color-accent); @@ -2050,6 +2060,13 @@ color: var(--color-warn); border-color: color-mix(in srgb, var(--color-warn) 40%, transparent); } +[data-iii-ui="browser"] .br-ui-chip-label { + margin-right: 4px; + flex-shrink: 0; + color: var(--color-ink-ghost); + text-transform: uppercase; + letter-spacing: 0.06em; +} [data-iii-ui="browser"] .br-ui-action-line { display: flex; align-items: flex-start; @@ -2084,6 +2101,249 @@ color: var(--color-accent); } +/* scraping function-trigger views */ +[data-iii-ui="browser"] .br-ui-scrape-section { + min-width: 0; + overflow: hidden; + border-top: 1px solid var(--color-rule-2); + background: var(--color-bg); + color: var(--color-ink); + font-family: var(--font-mono, ui-monospace, monospace); +} +[data-iii-ui="browser"] .br-ui-scrape-section.is-preview { + border-top: 0; + border-bottom: 1px solid var(--color-rule-2); +} +[data-iii-ui="browser"] .br-ui-scrape-section pre { + max-width: 100%; + overflow: auto; +} +[data-iii-ui="browser"] .br-ui-scrape-running, +[data-iii-ui="browser"] .br-ui-scrape-empty { + padding: 12px; + font-size: 12.5px; + color: var(--color-ink-ghost); +} +[data-iii-ui="browser"] .br-ui-scrape-label { + padding: 6px 12px; + border-bottom: 1px solid var(--color-rule-2); + background: var(--color-paper-2); + color: var(--color-ink-faint); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; +} +[data-iii-ui="browser"] .br-ui-scrape-label.is-separated { + border-top: 1px solid var(--color-rule-2); +} +[data-iii-ui="browser"] .br-ui-scrape-pre { + margin: 0; + padding: 8px 12px; + font-size: 12px; + line-height: 1.55; + color: var(--color-ink); + white-space: pre-wrap; + overflow-wrap: anywhere; +} +[data-iii-ui="browser"] .br-ui-scrape-pre.is-muted { + color: var(--color-ink-faint); + font-size: 11.5px; + line-height: 1.5; +} +[data-iii-ui="browser"] .br-ui-scrape-pre.is-separated { + border-top: 1px solid var(--color-rule-2); +} +[data-iii-ui="browser"] .br-ui-scrape-result-row { + display: flex; + min-width: 0; + align-items: flex-start; + gap: 8px; + padding: 6px 12px; + border-bottom: 1px solid var(--color-rule-2); + font-size: 12px; +} +[data-iii-ui="browser"] .br-ui-scrape-result-row:last-child { + border-bottom: 0; +} +[data-iii-ui="browser"] .br-ui-scrape-row-status { + flex-shrink: 0; + color: var(--color-ink-faint); + font-variant-numeric: tabular-nums; +} +[data-iii-ui="browser"] .br-ui-scrape-row-status.is-warn, +[data-iii-ui="browser"] .br-ui-scrape-summary.is-warn, +[data-iii-ui="browser"] .br-ui-scrape-row-error { + color: var(--color-warn); +} +[data-iii-ui="browser"] .br-ui-scrape-row-main, +[data-iii-ui="browser"] .br-ui-scrape-row-error { + min-width: 0; + overflow-wrap: anywhere; +} +[data-iii-ui="browser"] .br-ui-scrape-row-main { + color: var(--color-ink); +} +[data-iii-ui="browser"] .br-ui-scrape-summary { + color: var(--color-ink-faint); + overflow-wrap: anywhere; +} +[data-iii-ui="browser"] .br-ui-scrape-break, +[data-iii-ui="browser"] .br-ui-scrape-selector-value, +[data-iii-ui="browser"] .br-ui-scrape-caption, +[data-iii-ui="browser"] .br-ui-scrape-detail { + overflow-wrap: anywhere; +} +[data-iii-ui="browser"] .br-ui-scrape-detail, +[data-iii-ui="browser"] .br-ui-scrape-caption { + color: var(--color-ink-faint); + font-size: 11.5px; +} +[data-iii-ui="browser"] .br-ui-scrape-more { + padding: 6px 12px; + color: var(--color-ink-ghost); + font-size: 11px; +} +[data-iii-ui="browser"] .br-ui-scrape-more.is-separated { + border-top: 1px solid var(--color-rule-2); +} +[data-iii-ui="browser"] .br-ui-scrape-warning { + color: var(--color-warn); + border-color: color-mix(in srgb, var(--color-warn) 40%, transparent); + text-transform: uppercase; + letter-spacing: 0.06em; +} +[data-iii-ui="browser"] .br-ui-scrape-adaptive { + color: var(--color-accent); + border-color: color-mix(in srgb, var(--color-accent) 40%, transparent); + text-transform: uppercase; + letter-spacing: 0.06em; +} +[data-iii-ui="browser"] .br-ui-scrape-chip-value { + margin-left: 4px; + color: var(--color-ink); + text-transform: none; + letter-spacing: normal; +} +[data-iii-ui="browser"] .br-ui-scrape-selector-list { + padding: 2px 0; + border-bottom: 1px solid var(--color-rule-2); +} +[data-iii-ui="browser"] .br-ui-scrape-selector-row { + display: flex; + min-width: 0; + align-items: flex-start; + gap: 8px; + padding: 2px 12px; + font-size: 11.5px; +} +[data-iii-ui="browser"] .br-ui-scrape-selector-name, +[data-iii-ui="browser"] .br-ui-scrape-element-tag { + flex-shrink: 0; + color: var(--color-accent); +} +[data-iii-ui="browser"] .br-ui-scrape-selector-value { + min-width: 0; + color: var(--color-ink-faint); + font-size: 11px; +} +[data-iii-ui="browser"] .br-ui-scrape-result-number { + width: 24px; + flex-shrink: 0; + color: var(--color-ink-ghost); + text-align: right; + font-variant-numeric: tabular-nums; +} +[data-iii-ui="browser"] .br-ui-scrape-dim { + color: var(--color-ink-ghost); +} +[data-iii-ui="browser"] .br-ui-scrape-prewrap { + white-space: pre-wrap; +} +[data-iii-ui="browser"] .br-ui-scrape-element-row { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + padding: 6px 12px; + border-bottom: 1px solid var(--color-rule-2); +} +[data-iii-ui="browser"] .br-ui-scrape-element-row:last-child { + border-bottom: 0; +} +[data-iii-ui="browser"] .br-ui-scrape-element-main { + display: flex; + min-width: 0; + align-items: baseline; + gap: 8px; + font-size: 12px; +} +[data-iii-ui="browser"] .br-ui-scrape-description { + padding: 8px 12px; + border-bottom: 1px solid var(--color-rule-2); + color: var(--color-ink); + font-size: 12.5px; + overflow-wrap: anywhere; +} +[data-iii-ui="browser"] .br-ui-scrape-table-wrap { + max-width: 100%; + overflow-x: auto; +} +[data-iii-ui="browser"] .br-ui-scrape-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + color: var(--color-ink); + font-size: 11.5px; +} +[data-iii-ui="browser"] .br-ui-scrape-table tr { + border-bottom: 1px solid var(--color-rule-2); +} +[data-iii-ui="browser"] .br-ui-scrape-table tr:last-child { + border-bottom: 0; +} +[data-iii-ui="browser"] .br-ui-scrape-table td { + padding: 4px 12px; + overflow-wrap: anywhere; +} +[data-iii-ui="browser"] .br-ui-scrape-table-key { + width: 26%; + color: var(--color-ink-faint); + vertical-align: top; +} +[data-iii-ui="browser"] .br-ui-scrape-table-value { + color: var(--color-ink); +} +[data-iii-ui="browser"] .br-ui-scrape-table-type { + width: 22%; + color: var(--color-accent); +} +[data-iii-ui="browser"] .br-ui-scrape-table-meta { + color: var(--color-ink-faint); + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +[data-iii-ui="browser"] .br-ui-scrape-gallery { + display: grid; + gap: 8px; + max-width: 100%; + padding: 12px; +} +[data-iii-ui="browser"] .br-ui-scrape-image { + display: block; + max-width: 100%; + max-height: 420px; + border: 1px solid var(--color-rule-2); + background: var(--color-paper-2); + object-fit: contain; +} +[data-iii-ui="browser"] .br-ui-scrape-num { + font-variant-numeric: tabular-nums; +} +[data-iii-ui="browser"] .br-ui-scrape-unit { + margin-left: 2px; +} + /* snapshot / dom trees */ [data-iii-ui="browser"] .br-ui-tree { margin: 0; diff --git a/browser/vendor/SCRAPLING-0.4.9-LICENSE b/browser/vendor/SCRAPLING-0.4.9-LICENSE new file mode 100644 index 000000000..3abe05c0c --- /dev/null +++ b/browser/vendor/SCRAPLING-0.4.9-LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2024, Karim shoair + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/browser/vendor/browserforge-1.2.4/NOTICE b/browser/vendor/browserforge-1.2.4/NOTICE new file mode 100644 index 000000000..b51c51dd4 --- /dev/null +++ b/browser/vendor/browserforge-1.2.4/NOTICE @@ -0,0 +1,10 @@ +BrowserForge 1.2.4 and apify-fingerprint-datapoints 0.15.0 data +Copyright 2018 Apify Technologies s.r.o. + +The input and header Bayesian-network snapshots are redistributed under the +Apache License, Version 2.0. Their frozen SHA-256 digests are: + + input-network.json 85e51ab98b64f07255da7767c128c9e5f171af1ff2448a0a2e4688ffaa455a3f + header-network.json a30900f8cdee234113947630e46417042ac31391dd5dbc505359625899a47052 + +https://github.com/apify/fingerprint-suite diff --git a/browser/vendor/browserforge-1.2.4/header-network.json b/browser/vendor/browserforge-1.2.4/header-network.json new file mode 100644 index 000000000..e4b4fa9c0 --- /dev/null +++ b/browser/vendor/browserforge-1.2.4/header-network.json @@ -0,0 +1 @@ +{"nodes":[{"name":"*BROWSER","parentNames":[],"possibleValues":["chrome/147.0.0.0","edge/147.0.0.0","safari/26.4","safari/26.3","chrome/146.0.0.0","firefox/150.0","safari/26.2","safari/18.5","safari/26.0.1","firefox/135.0","chrome/148.0.0.0","chrome/142.0.0.0","safari/18.3","chrome/144.0.0.0","safari/18.2","safari/26.3.1","safari/18.7","edge/129.0.0.0","safari/18.7.4","safari/18.1","safari/18.6","safari/18.7.5","chrome/145.0.0.0","chrome/127.0.0.0","safari/16.6","chrome/141.0.0.0","chrome/121.0.0.0","chrome/143.0.0.0","chrome/128.0.0.0","safari/17.2.1","safari/26.5","chrome/116.0.0.0","chrome/138.0.0.0","safari/26.1","safari/17.6","firefox/151.0","chrome/130.0.0.0","chrome/115.0.0.0","edge/146.0.0.0","chrome/149.0.0.0","chrome/140.0.0.0","safari/16.4","safari/18.1.1","edge/144.0.0.0","edge/128.0.0.0","chrome/109.0.0.0","chrome/139.0.0.0","edge/143.0.0.0","safari/17.4","chrome/131.0.0.0","safari/18.4","safari/18.0.1","chrome/134.0.0.0","safari/16.5.2","safari/26.4.2","chrome/120.0.0.0","chrome/137.0.0.0","safari/26.0","safari/16.6.1","firefox/147.0","edge/145.0.0.0","chrome/135.0.0.0","safari/17.5","chrome/45.0.8909.1591","chrome/91.0.4450.0","safari/17.7","chrome/125.0.0.0","chrome/126.0.0.0","chrome/113.0.0.0","chrome/101.0.4951.54","chrome/108.0.0.0","edge/148.0.0.0","chrome/147.0.7727.56","safari/17.4.1","edge/123.0.0.0","chrome/136.0.0.0","safari/16.6.2","chrome/119.0.0.0","chrome/142.0.7444.138","safari/18.3.1","edge/138.0.0.0","firefox/136.0","chrome/114.0.0.0","safari/16.5","chrome/138.0.7204.235","chrome/59.0.9273.1293","chrome/144.0.7559.95","safari/17.3","edge/142.0.0.0","safari/18.7.3","chrome/138.0.7204.63","firefox/149.0","safari/17.8","safari/15.6.8","safari/17.3.1","chrome/147.0.7727.111","chrome/124.0.0.0","chrome/122.0.0.0","safari/15.6.7","chrome/107.0.0.0","firefox/146.0","edge/122.0.0.0","safari/16.2","chrome/103.0.0.0","chrome/90.0.4430.212","safari/17.1.2","chrome/132.0.0.0","safari/18.0","chrome/130.0.6723.73","chrome/133.0.0.0","firefox/142.0","safari/17.0","safari/17.1","chrome/96.0.4664.110","safari/18.7.7","chrome/147.0.7727.116","safari/18.7.2","safari/15.6.1","safari/17.2","safari/18.4.1","edge/135.0.3179.54","safari/26.4.1","chrome/106.0.0.0","safari/16.1","chrome/91.0.4472.124","chrome/132.0.6788.76","safari/16.3"],"conditionalProbabilities":{"chrome/147.0.0.0":0.6648816234498309,"edge/147.0.0.0":0.06729143179255918,"safari/26.4":0.04594137542277339,"safari/26.3":0.038331454340473504,"chrome/146.0.0.0":0.031426155580608794,"firefox/150.0":0.004086809470124014,"safari/26.2":0.012612739571589628,"safari/18.5":0.0036640360766629085,"safari/26.0.1":0.0024661781285231117,"firefox/135.0":0.0014092446448703494,"chrome/148.0.0.0":0.007116685456595265,"chrome/142.0.0.0":0.007750845546786922,"safari/18.3":0.0017615558060879368,"chrome/144.0.0.0":0.004227733934611048,"safari/18.2":0.0006341600901916572,"safari/26.3.1":0.018602029312288614,"safari/18.7":0.0002818489289740699,"edge/129.0.0.0":0.00021138669673055241,"safari/18.7.4":0.0002818489289740699,"safari/18.1":0.0015501691093573844,"safari/18.6":0.011203494926719279,"safari/18.7.5":0.0031003382187147687,"chrome/145.0.0.0":0.01677001127395716,"chrome/127.0.0.0":0.00021138669673055241,"safari/16.6":0.00042277339346110483,"chrome/141.0.0.0":0.0014092446448703494,"chrome/121.0.0.0":0.0002818489289740699,"chrome/143.0.0.0":0.004086809470124014,"chrome/128.0.0.0":0.0005636978579481398,"safari/17.2.1":0.00007046223224351747,"safari/26.5":0.004368658399098084,"chrome/116.0.0.0":0.00035231116121758736,"chrome/138.0.0.0":0.0042981961668545655,"safari/26.1":0.0034526493799323563,"safari/17.6":0.002395715896279594,"firefox/151.0":0.00014092446448703494,"chrome/130.0.0.0":0.0005636978579481398,"chrome/115.0.0.0":0.00007046223224351747,"edge/146.0.0.0":0.002043404735062007,"chrome/149.0.0.0":0.0007750845546786922,"chrome/140.0.0.0":0.0009864712514092446,"safari/16.4":0.00014092446448703494,"safari/18.1.1":0.00035231116121758736,"edge/144.0.0.0":0.00014092446448703494,"edge/128.0.0.0":0.00007046223224351747,"chrome/109.0.0.0":0.0017615558060879368,"chrome/139.0.0.0":0.0009160090191657271,"edge/143.0.0.0":0.0007750845546786922,"safari/17.4":0.00021138669673055241,"chrome/131.0.0.0":0.0007750845546786922,"safari/18.4":0.00042277339346110483,"safari/18.0.1":0.00007046223224351747,"chrome/134.0.0.0":0.0007046223224351747,"safari/16.5.2":0.00007046223224351747,"safari/26.4.2":0.0005636978579481398,"chrome/120.0.0.0":0.0002818489289740699,"chrome/137.0.0.0":0.0008455467869222097,"safari/26.0":0.0016206313416009018,"safari/16.6.1":0.00021138669673055241,"firefox/147.0":0.0002818489289740699,"edge/145.0.0.0":0.00035231116121758736,"chrome/135.0.0.0":0.0007750845546786922,"safari/17.5":0.0009160090191657271,"chrome/45.0.8909.1591":0.00007046223224351747,"chrome/91.0.4450.0":0.00021138669673055241,"safari/17.7":0.00014092446448703494,"chrome/125.0.0.0":0.0007046223224351747,"chrome/126.0.0.0":0.0006341600901916572,"chrome/113.0.0.0":0.00007046223224351747,"chrome/101.0.4951.54":0.0007046223224351747,"chrome/108.0.0.0":0.00014092446448703494,"edge/148.0.0.0":0.0012683201803833145,"chrome/147.0.7727.56":0.00035231116121758736,"safari/17.4.1":0.00042277339346110483,"edge/123.0.0.0":0.00021138669673055241,"chrome/136.0.0.0":0.00042277339346110483,"safari/16.6.2":0.00035231116121758736,"chrome/119.0.0.0":0.00042277339346110483,"chrome/142.0.7444.138":0.00007046223224351747,"safari/18.3.1":0.0005636978579481398,"edge/138.0.0.0":0.00007046223224351747,"firefox/136.0":0.00007046223224351747,"chrome/114.0.0.0":0.0008455467869222097,"safari/16.5":0.00042277339346110483,"chrome/138.0.7204.235":0.00007046223224351747,"chrome/59.0.9273.1293":0.00007046223224351747,"chrome/144.0.7559.95":0.00014092446448703494,"safari/17.3":0.00021138669673055241,"edge/142.0.0.0":0.00014092446448703494,"safari/18.7.3":0.00021138669673055241,"chrome/138.0.7204.63":0.00007046223224351747,"firefox/149.0":0.0004932356257046223,"safari/17.8":0.00007046223224351747,"safari/15.6.8":0.0008455467869222097,"safari/17.3.1":0.0002818489289740699,"chrome/147.0.7727.111":0.00042277339346110483,"chrome/124.0.0.0":0.00021138669673055241,"chrome/122.0.0.0":0.00035231116121758736,"safari/15.6.7":0.00035231116121758736,"chrome/107.0.0.0":0.00014092446448703494,"firefox/146.0":0.00014092446448703494,"edge/122.0.0.0":0.00007046223224351747,"safari/16.2":0.00007046223224351747,"chrome/103.0.0.0":0.00007046223224351747,"chrome/90.0.4430.212":0.00007046223224351747,"safari/17.1.2":0.00007046223224351747,"chrome/132.0.0.0":0.00021138669673055241,"safari/18.0":0.00035231116121758736,"chrome/130.0.6723.73":0.00007046223224351747,"chrome/133.0.0.0":0.00035231116121758736,"firefox/142.0":0.00014092446448703494,"safari/17.0":0.00007046223224351747,"safari/17.1":0.00014092446448703494,"chrome/96.0.4664.110":0.00007046223224351747,"safari/18.7.7":0.00007046223224351747,"chrome/147.0.7727.116":0.0002818489289740699,"safari/18.7.2":0.00014092446448703494,"safari/15.6.1":0.00007046223224351747,"safari/17.2":0.00007046223224351747,"safari/18.4.1":0.00007046223224351747,"edge/135.0.3179.54":0.00007046223224351747,"safari/26.4.1":0.00007046223224351747,"chrome/106.0.0.0":0.00014092446448703494,"safari/16.1":0.00007046223224351747,"chrome/91.0.4472.124":0.0002818489289740699,"chrome/132.0.6788.76":0.00007046223224351747,"safari/16.3":0.00007046223224351747}},{"name":"*OPERATING_SYSTEM","parentNames":[],"possibleValues":["macos","windows","ios","android","linux","*MISSING_VALUE*"],"conditionalProbabilities":{"macos":0.28734498308906425,"windows":0.5926578354002254,"ios":0.07278748590755355,"android":0.023322998872604285,"linux":0.02290022547914318,"*MISSING_VALUE*":0.0009864712514092446}},{"name":"*DEVICE","parentNames":[],"possibleValues":["desktop","mobile"],"conditionalProbabilities":{"desktop":0.9038895152198422,"mobile":0.09611048478015784}},{"name":"*HTTP_VERSION","parentNames":[],"possibleValues":["_2.0_","_1.1_"],"conditionalProbabilities":{"_2.0_":0.9921786922209695,"_1.1_":0.00782130777903044}},{"name":"sec-ch-ua-mobile","parentNames":["*HTTP_VERSION","*DEVICE","*OPERATING_SYSTEM","*BROWSER"],"possibleValues":["?0","*MISSING_VALUE*","?1"],"conditionalProbabilities":{"deeper":{"_2.0_":{"deeper":{"desktop":{"deeper":{"macos":{"deeper":{"chrome/147.0.0.0":{"?0":0.9977548271216884,"*MISSING_VALUE*":0.00224517287831163},"edge/147.0.0.0":{"?0":1},"safari/26.4":{"*MISSING_VALUE*":1},"safari/26.3":{"*MISSING_VALUE*":1},"chrome/146.0.0.0":{"?0":1},"firefox/150.0":{"*MISSING_VALUE*":1},"safari/26.2":{"*MISSING_VALUE*":1},"safari/18.5":{"*MISSING_VALUE*":1},"safari/26.0.1":{"*MISSING_VALUE*":1},"firefox/135.0":{"*MISSING_VALUE*":1},"chrome/148.0.0.0":{"?0":1},"chrome/142.0.0.0":{"?0":1},"safari/18.3":{"*MISSING_VALUE*":1},"chrome/144.0.0.0":{"?0":1},"safari/18.2":{"*MISSING_VALUE*":1},"safari/26.3.1":{"*MISSING_VALUE*":1},"safari/18.1":{"*MISSING_VALUE*":1},"safari/18.6":{"*MISSING_VALUE*":1},"chrome/145.0.0.0":{"?0":1},"chrome/127.0.0.0":{"?0":1},"safari/16.6":{"*MISSING_VALUE*":1},"chrome/141.0.0.0":{"?0":1},"chrome/143.0.0.0":{"?0":1},"chrome/128.0.0.0":{"?0":1},"safari/17.2.1":{"*MISSING_VALUE*":1},"safari/26.5":{"*MISSING_VALUE*":1},"chrome/116.0.0.0":{"?0":1},"chrome/138.0.0.0":{"?0":1},"safari/26.1":{"*MISSING_VALUE*":1},"safari/17.6":{"*MISSING_VALUE*":1},"firefox/151.0":{"*MISSING_VALUE*":1},"chrome/130.0.0.0":{"?0":1},"chrome/115.0.0.0":{"?0":1},"chrome/149.0.0.0":{"?0":1},"chrome/140.0.0.0":{"?0":1},"safari/16.4":{"*MISSING_VALUE*":1},"safari/18.1.1":{"*MISSING_VALUE*":1},"chrome/139.0.0.0":{"?0":1},"safari/17.4":{"*MISSING_VALUE*":1},"chrome/131.0.0.0":{"?0":1},"safari/18.4":{"*MISSING_VALUE*":1},"safari/18.0.1":{"*MISSING_VALUE*":1},"safari/16.5.2":{"*MISSING_VALUE*":1},"safari/26.0":{"*MISSING_VALUE*":1},"safari/16.6.1":{"*MISSING_VALUE*":1},"firefox/147.0":{"*MISSING_VALUE*":1},"chrome/135.0.0.0":{"?0":1},"safari/17.5":{"*MISSING_VALUE*":1},"chrome/91.0.4450.0":{"*MISSING_VALUE*":1},"safari/17.4.1":{"*MISSING_VALUE*":1},"safari/18.3.1":{"*MISSING_VALUE*":1},"chrome/114.0.0.0":{"*MISSING_VALUE*":1},"safari/16.5":{"*MISSING_VALUE*":1},"chrome/138.0.7204.235":{"*MISSING_VALUE*":1},"safari/17.3":{"*MISSING_VALUE*":1},"firefox/149.0":{"*MISSING_VALUE*":1},"safari/15.6.8":{"*MISSING_VALUE*":1},"safari/17.3.1":{"*MISSING_VALUE*":1},"chrome/107.0.0.0":{"?0":1},"chrome/103.0.0.0":{"?0":1},"safari/17.1.2":{"*MISSING_VALUE*":1},"safari/18.0":{"*MISSING_VALUE*":1},"safari/17.0":{"*MISSING_VALUE*":1},"safari/17.1":{"*MISSING_VALUE*":1},"safari/15.6.1":{"*MISSING_VALUE*":1},"safari/17.2":{"*MISSING_VALUE*":1},"safari/16.1":{"*MISSING_VALUE*":1},"safari/16.3":{"*MISSING_VALUE*":1}},"skip":{"?0":0.6797047970479705,"*MISSING_VALUE*":0.3202952029520295}},"windows":{"deeper":{"chrome/147.0.0.0":{"?0":1},"edge/147.0.0.0":{"?0":1},"chrome/146.0.0.0":{"?0":0.9910714285714286,"*MISSING_VALUE*":0.008928571428571428},"firefox/150.0":{"*MISSING_VALUE*":1},"firefox/135.0":{"*MISSING_VALUE*":0.9473684210526315,"?0":0.05263157894736842},"chrome/148.0.0.0":{"?0":1},"chrome/142.0.0.0":{"?0":1},"chrome/144.0.0.0":{"?0":1},"edge/129.0.0.0":{"?0":1},"chrome/145.0.0.0":{"?0":1},"chrome/127.0.0.0":{"?0":1},"chrome/141.0.0.0":{"?0":1},"chrome/121.0.0.0":{"?0":1},"chrome/143.0.0.0":{"?0":1},"chrome/128.0.0.0":{"?0":1},"chrome/138.0.0.0":{"?0":1},"chrome/130.0.0.0":{"?0":1},"edge/146.0.0.0":{"?0":1},"chrome/149.0.0.0":{"?0":1},"chrome/140.0.0.0":{"?0":1},"edge/144.0.0.0":{"?0":1},"edge/128.0.0.0":{"?0":1},"chrome/109.0.0.0":{"?0":1},"chrome/139.0.0.0":{"?0":1},"edge/143.0.0.0":{"?0":1},"chrome/131.0.0.0":{"?0":1},"chrome/134.0.0.0":{"?0":1},"chrome/120.0.0.0":{"?0":1},"chrome/137.0.0.0":{"?0":1},"firefox/147.0":{"*MISSING_VALUE*":1},"edge/145.0.0.0":{"?0":1},"chrome/135.0.0.0":{"?0":1},"chrome/125.0.0.0":{"?0":1},"chrome/126.0.0.0":{"?0":1},"chrome/113.0.0.0":{"?0":1},"chrome/108.0.0.0":{"?0":1},"edge/148.0.0.0":{"?0":1},"chrome/147.0.7727.56":{"?0":1},"edge/123.0.0.0":{"?0":1},"chrome/136.0.0.0":{"?0":1},"edge/138.0.0.0":{"?0":1},"edge/142.0.0.0":{"?0":1},"firefox/149.0":{"*MISSING_VALUE*":1},"chrome/124.0.0.0":{"?0":1},"chrome/122.0.0.0":{"?0":1},"edge/122.0.0.0":{"?0":1},"chrome/132.0.0.0":{"?0":1},"chrome/133.0.0.0":{"?0":1},"edge/135.0.3179.54":{"?0":1},"chrome/106.0.0.0":{"?0":1},"chrome/91.0.4472.124":{"*MISSING_VALUE*":1},"chrome/132.0.6788.76":{"?0":1}},"skip":{"?0":0.9953113729261842,"*MISSING_VALUE*":0.004688627073815821}},"linux":{"deeper":{"chrome/147.0.0.0":{"?0":1},"edge/147.0.0.0":{"?0":1},"chrome/146.0.0.0":{"?0":1},"firefox/150.0":{"*MISSING_VALUE*":1},"chrome/148.0.0.0":{"?0":1},"chrome/142.0.0.0":{"?0":1},"chrome/144.0.0.0":{"?0":1},"chrome/145.0.0.0":{"?0":1},"chrome/141.0.0.0":{"?0":1},"chrome/143.0.0.0":{"?0":1},"chrome/138.0.0.0":{"?0":1},"chrome/130.0.0.0":{"?0":1},"chrome/149.0.0.0":{"?0":1},"chrome/140.0.0.0":{"?0":1},"edge/143.0.0.0":{"?0":1},"chrome/131.0.0.0":{"?0":1},"chrome/134.0.0.0":{"?0":1},"chrome/137.0.0.0":{"?0":1},"edge/145.0.0.0":{"?0":1},"chrome/135.0.0.0":{"?0":1},"chrome/126.0.0.0":{"?0":1},"chrome/101.0.4951.54":{"*MISSING_VALUE*":1},"chrome/136.0.0.0":{"?0":1},"firefox/149.0":{"*MISSING_VALUE*":1},"firefox/146.0":{"*MISSING_VALUE*":1},"chrome/90.0.4430.212":{"*MISSING_VALUE*":1},"firefox/142.0":{"*MISSING_VALUE*":1}},"skip":{"?0":0.9065420560747663,"*MISSING_VALUE*":0.09345794392523364}},"*MISSING_VALUE*":{"deeper":{"chrome/147.0.0.0":{"?0":1},"chrome/146.0.0.0":{"?0":1},"chrome/144.0.0.0":{"?0":1},"chrome/126.0.0.0":{"?0":1}},"skip":{"?0":1}}},"skip":{"deeper":{"chrome/147.0.0.0":{"?0":0.9994513332601778,"*MISSING_VALUE*":0.000548666739822232},"edge/147.0.0.0":{"?0":1},"safari/26.4":{"*MISSING_VALUE*":1},"safari/26.3":{"*MISSING_VALUE*":1},"chrome/146.0.0.0":{"?0":0.9977116704805492,"*MISSING_VALUE*":0.002288329519450801},"firefox/150.0":{"*MISSING_VALUE*":1},"safari/26.2":{"*MISSING_VALUE*":1},"safari/18.5":{"*MISSING_VALUE*":1},"safari/26.0.1":{"*MISSING_VALUE*":1},"firefox/135.0":{"*MISSING_VALUE*":0.95,"?0":0.05},"chrome/148.0.0.0":{"?0":1},"chrome/142.0.0.0":{"?0":1},"safari/18.3":{"*MISSING_VALUE*":1},"chrome/144.0.0.0":{"?0":1},"safari/18.2":{"*MISSING_VALUE*":1},"safari/26.3.1":{"*MISSING_VALUE*":1},"edge/129.0.0.0":{"?0":1},"safari/18.1":{"*MISSING_VALUE*":1},"safari/18.6":{"*MISSING_VALUE*":1},"chrome/145.0.0.0":{"?0":1},"chrome/127.0.0.0":{"?0":1},"safari/16.6":{"*MISSING_VALUE*":1},"chrome/141.0.0.0":{"?0":1},"chrome/121.0.0.0":{"?0":1},"chrome/143.0.0.0":{"?0":1},"chrome/128.0.0.0":{"?0":1},"safari/17.2.1":{"*MISSING_VALUE*":1},"safari/26.5":{"*MISSING_VALUE*":1},"chrome/116.0.0.0":{"?0":1},"chrome/138.0.0.0":{"?0":1},"safari/26.1":{"*MISSING_VALUE*":1},"safari/17.6":{"*MISSING_VALUE*":1},"firefox/151.0":{"*MISSING_VALUE*":1},"chrome/130.0.0.0":{"?0":1},"chrome/115.0.0.0":{"?0":1},"edge/146.0.0.0":{"?0":1},"chrome/149.0.0.0":{"?0":1},"chrome/140.0.0.0":{"?0":1},"safari/16.4":{"*MISSING_VALUE*":1},"safari/18.1.1":{"*MISSING_VALUE*":1},"edge/144.0.0.0":{"?0":1},"edge/128.0.0.0":{"?0":1},"chrome/109.0.0.0":{"?0":1},"chrome/139.0.0.0":{"?0":1},"edge/143.0.0.0":{"?0":1},"safari/17.4":{"*MISSING_VALUE*":1},"chrome/131.0.0.0":{"?0":1},"safari/18.4":{"*MISSING_VALUE*":1},"safari/18.0.1":{"*MISSING_VALUE*":1},"chrome/134.0.0.0":{"?0":1},"safari/16.5.2":{"*MISSING_VALUE*":1},"chrome/120.0.0.0":{"?0":1},"chrome/137.0.0.0":{"?0":1},"safari/26.0":{"*MISSING_VALUE*":1},"safari/16.6.1":{"*MISSING_VALUE*":1},"firefox/147.0":{"*MISSING_VALUE*":1},"edge/145.0.0.0":{"?0":1},"chrome/135.0.0.0":{"?0":1},"safari/17.5":{"*MISSING_VALUE*":1},"chrome/91.0.4450.0":{"*MISSING_VALUE*":1},"chrome/125.0.0.0":{"?0":1},"chrome/126.0.0.0":{"?0":1},"chrome/113.0.0.0":{"?0":1},"chrome/101.0.4951.54":{"*MISSING_VALUE*":1},"chrome/108.0.0.0":{"?0":1},"edge/148.0.0.0":{"?0":1},"chrome/147.0.7727.56":{"?0":1},"safari/17.4.1":{"*MISSING_VALUE*":1},"edge/123.0.0.0":{"?0":1},"chrome/136.0.0.0":{"?0":1},"safari/18.3.1":{"*MISSING_VALUE*":1},"edge/138.0.0.0":{"?0":1},"chrome/114.0.0.0":{"*MISSING_VALUE*":1},"safari/16.5":{"*MISSING_VALUE*":1},"chrome/138.0.7204.235":{"*MISSING_VALUE*":1},"safari/17.3":{"*MISSING_VALUE*":1},"edge/142.0.0.0":{"?0":1},"firefox/149.0":{"*MISSING_VALUE*":1},"safari/15.6.8":{"*MISSING_VALUE*":1},"safari/17.3.1":{"*MISSING_VALUE*":1},"chrome/124.0.0.0":{"?0":1},"chrome/122.0.0.0":{"?0":1},"chrome/107.0.0.0":{"?0":1},"firefox/146.0":{"*MISSING_VALUE*":1},"edge/122.0.0.0":{"?0":1},"chrome/103.0.0.0":{"?0":1},"chrome/90.0.4430.212":{"*MISSING_VALUE*":1},"safari/17.1.2":{"*MISSING_VALUE*":1},"chrome/132.0.0.0":{"?0":1},"safari/18.0":{"*MISSING_VALUE*":1},"chrome/133.0.0.0":{"?0":1},"firefox/142.0":{"*MISSING_VALUE*":1},"safari/17.0":{"*MISSING_VALUE*":1},"safari/17.1":{"*MISSING_VALUE*":1},"safari/15.6.1":{"*MISSING_VALUE*":1},"safari/17.2":{"*MISSING_VALUE*":1},"edge/135.0.3179.54":{"?0":1},"chrome/106.0.0.0":{"?0":1},"safari/16.1":{"*MISSING_VALUE*":1},"chrome/91.0.4472.124":{"*MISSING_VALUE*":1},"chrome/132.0.6788.76":{"?0":1},"safari/16.3":{"*MISSING_VALUE*":1}},"skip":{"?0":0.8921915546119368,"*MISSING_VALUE*":0.10780844538806322}}},"mobile":{"deeper":{"ios":{"deeper":{"safari/26.4":{"*MISSING_VALUE*":0.9964285714285714,"?0":0.0035714285714285713},"safari/26.3":{"*MISSING_VALUE*":1},"safari/26.2":{"*MISSING_VALUE*":1},"safari/18.5":{"?0":0.07692307692307693,"*MISSING_VALUE*":0.9230769230769231},"safari/26.0.1":{"*MISSING_VALUE*":1},"safari/18.3":{"*MISSING_VALUE*":1},"safari/18.2":{"*MISSING_VALUE*":1},"safari/26.3.1":{"*MISSING_VALUE*":1},"safari/18.7":{"*MISSING_VALUE*":1},"safari/18.7.4":{"*MISSING_VALUE*":1},"safari/18.6":{"*MISSING_VALUE*":1},"safari/18.7.5":{"*MISSING_VALUE*":1},"safari/26.5":{"*MISSING_VALUE*":1},"safari/26.1":{"*MISSING_VALUE*":1},"safari/17.6":{"*MISSING_VALUE*":1},"safari/18.4":{"*MISSING_VALUE*":1},"safari/26.4.2":{"*MISSING_VALUE*":1},"safari/26.0":{"*MISSING_VALUE*":1},"safari/16.6.1":{"*MISSING_VALUE*":1},"safari/17.5":{"*MISSING_VALUE*":1},"chrome/45.0.8909.1591":{"*MISSING_VALUE*":1},"safari/17.7":{"*MISSING_VALUE*":1},"safari/17.4.1":{"*MISSING_VALUE*":1},"safari/16.6.2":{"*MISSING_VALUE*":1},"safari/18.3.1":{"*MISSING_VALUE*":1},"safari/16.5":{"*MISSING_VALUE*":1},"chrome/144.0.7559.95":{"*MISSING_VALUE*":1},"safari/18.7.3":{"*MISSING_VALUE*":1},"safari/17.8":{"*MISSING_VALUE*":1},"safari/15.6.8":{"*MISSING_VALUE*":1},"safari/15.6.7":{"*MISSING_VALUE*":1},"safari/16.2":{"*MISSING_VALUE*":1},"safari/18.0":{"*MISSING_VALUE*":1},"safari/18.7.7":{"*MISSING_VALUE*":1},"safari/18.7.2":{"*MISSING_VALUE*":1},"safari/18.4.1":{"*MISSING_VALUE*":1},"safari/26.4.1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":0.9980638915779284,"?0":0.001936108422071636}},"android":{"deeper":{"chrome/147.0.0.0":{"?1":0.984251968503937,"?0":0.015748031496062992},"edge/147.0.0.0":{"?1":1},"chrome/146.0.0.0":{"?1":0.3333333333333333,"?0":0.6666666666666666},"firefox/150.0":{"*MISSING_VALUE*":1},"chrome/148.0.0.0":{"?1":1},"chrome/144.0.0.0":{"?1":1},"chrome/145.0.0.0":{"?1":1},"chrome/143.0.0.0":{"?1":1},"chrome/138.0.0.0":{"?1":1},"edge/143.0.0.0":{"?1":1},"chrome/134.0.0.0":{"?1":1},"chrome/120.0.0.0":{"?1":1},"chrome/137.0.0.0":{"?1":1},"chrome/119.0.0.0":{"?1":1},"chrome/142.0.7444.138":{"?1":1},"firefox/136.0":{"*MISSING_VALUE*":1},"chrome/59.0.9273.1293":{"*MISSING_VALUE*":1},"chrome/138.0.7204.63":{"*MISSING_VALUE*":1},"chrome/147.0.7727.111":{"?1":0.16666666666666666,"*MISSING_VALUE*":0.8333333333333334},"chrome/130.0.6723.73":{"?1":1}},"skip":{"?1":0.9244712990936556,"*MISSING_VALUE*":0.05740181268882175,"?0":0.01812688821752266}}},"skip":{"deeper":{"chrome/147.0.0.0":{"?1":0.984251968503937,"?0":0.015748031496062992},"edge/147.0.0.0":{"?1":1},"safari/26.4":{"*MISSING_VALUE*":0.9964285714285714,"?0":0.0035714285714285713},"safari/26.3":{"*MISSING_VALUE*":1},"chrome/146.0.0.0":{"?1":0.3333333333333333,"?0":0.6666666666666666},"firefox/150.0":{"*MISSING_VALUE*":1},"safari/26.2":{"*MISSING_VALUE*":1},"safari/18.5":{"?0":0.07692307692307693,"*MISSING_VALUE*":0.9230769230769231},"safari/26.0.1":{"*MISSING_VALUE*":1},"chrome/148.0.0.0":{"?1":1},"safari/18.3":{"*MISSING_VALUE*":1},"chrome/144.0.0.0":{"?1":1},"safari/18.2":{"*MISSING_VALUE*":1},"safari/26.3.1":{"*MISSING_VALUE*":1},"safari/18.7":{"*MISSING_VALUE*":1},"safari/18.7.4":{"*MISSING_VALUE*":1},"safari/18.6":{"*MISSING_VALUE*":1},"safari/18.7.5":{"*MISSING_VALUE*":1},"chrome/145.0.0.0":{"?1":1},"chrome/143.0.0.0":{"?1":1},"safari/26.5":{"*MISSING_VALUE*":1},"chrome/138.0.0.0":{"?1":1},"safari/26.1":{"*MISSING_VALUE*":1},"safari/17.6":{"*MISSING_VALUE*":1},"edge/143.0.0.0":{"?1":1},"safari/18.4":{"*MISSING_VALUE*":1},"chrome/134.0.0.0":{"?1":1},"safari/26.4.2":{"*MISSING_VALUE*":1},"chrome/120.0.0.0":{"?1":1},"chrome/137.0.0.0":{"?1":1},"safari/26.0":{"*MISSING_VALUE*":1},"safari/16.6.1":{"*MISSING_VALUE*":1},"safari/17.5":{"*MISSING_VALUE*":1},"chrome/45.0.8909.1591":{"*MISSING_VALUE*":1},"safari/17.7":{"*MISSING_VALUE*":1},"safari/17.4.1":{"*MISSING_VALUE*":1},"safari/16.6.2":{"*MISSING_VALUE*":1},"chrome/119.0.0.0":{"?1":1},"chrome/142.0.7444.138":{"?1":1},"safari/18.3.1":{"*MISSING_VALUE*":1},"firefox/136.0":{"*MISSING_VALUE*":1},"safari/16.5":{"*MISSING_VALUE*":1},"chrome/59.0.9273.1293":{"*MISSING_VALUE*":1},"chrome/144.0.7559.95":{"*MISSING_VALUE*":1},"safari/18.7.3":{"*MISSING_VALUE*":1},"chrome/138.0.7204.63":{"*MISSING_VALUE*":1},"safari/17.8":{"*MISSING_VALUE*":1},"safari/15.6.8":{"*MISSING_VALUE*":1},"chrome/147.0.7727.111":{"?1":0.16666666666666666,"*MISSING_VALUE*":0.8333333333333334},"safari/15.6.7":{"*MISSING_VALUE*":1},"safari/16.2":{"*MISSING_VALUE*":1},"safari/18.0":{"*MISSING_VALUE*":1},"chrome/130.0.6723.73":{"?1":1},"safari/18.7.7":{"*MISSING_VALUE*":1},"safari/18.7.2":{"*MISSING_VALUE*":1},"safari/18.4.1":{"*MISSING_VALUE*":1},"safari/26.4.1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":0.7697947214076246,"?1":0.22434017595307917,"?0":0.005865102639296188}}}}},"_1.1_":{"deeper":{"desktop":{"deeper":{"macos":{"deeper":{"chrome/147.0.0.0":{"?0":1},"safari/26.4":{"*MISSING_VALUE*":1},"safari/18.5":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":0.23076923076923078,"?0":0.7692307692307693}},"windows":{"deeper":{"chrome/147.0.0.0":{"?0":1},"edge/147.0.0.0":{"?0":0.9444444444444444,"*MISSING_VALUE*":0.05555555555555555},"chrome/146.0.0.0":{"?0":1},"chrome/142.0.0.0":{"?0":1},"chrome/145.0.0.0":{"?0":1},"chrome/143.0.0.0":{"?0":1},"chrome/116.0.0.0":{"*MISSING_VALUE*":1},"edge/146.0.0.0":{"?0":1},"chrome/96.0.4664.110":{"*MISSING_VALUE*":1}},"skip":{"?0":0.967741935483871,"*MISSING_VALUE*":0.03225806451612903}},"linux":{"deeper":{"chrome/147.0.7727.116":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"*MISSING_VALUE*":{"deeper":{"chrome/147.0.0.0":{"?0":1}},"skip":{"?0":1}}},"skip":{"deeper":{"chrome/147.0.0.0":{"?0":1},"edge/147.0.0.0":{"?0":0.9444444444444444,"*MISSING_VALUE*":0.05555555555555555},"safari/26.4":{"*MISSING_VALUE*":1},"chrome/146.0.0.0":{"?0":1},"safari/18.5":{"*MISSING_VALUE*":1},"chrome/142.0.0.0":{"?0":1},"chrome/145.0.0.0":{"?0":1},"chrome/143.0.0.0":{"?0":1},"chrome/116.0.0.0":{"*MISSING_VALUE*":1},"edge/146.0.0.0":{"?0":1},"chrome/96.0.4664.110":{"*MISSING_VALUE*":1},"chrome/147.0.7727.116":{"*MISSING_VALUE*":1}},"skip":{"?0":0.9099099099099099,"*MISSING_VALUE*":0.09009009009009009}}}},"skip":{"deeper":{"macos":{"deeper":{"chrome/147.0.0.0":{"?0":1},"safari/26.4":{"*MISSING_VALUE*":1},"safari/18.5":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":0.23076923076923078,"?0":0.7692307692307693}},"windows":{"deeper":{"chrome/147.0.0.0":{"?0":1},"edge/147.0.0.0":{"?0":0.9444444444444444,"*MISSING_VALUE*":0.05555555555555555},"chrome/146.0.0.0":{"?0":1},"chrome/142.0.0.0":{"?0":1},"chrome/145.0.0.0":{"?0":1},"chrome/143.0.0.0":{"?0":1},"chrome/116.0.0.0":{"*MISSING_VALUE*":1},"edge/146.0.0.0":{"?0":1},"chrome/96.0.4664.110":{"*MISSING_VALUE*":1}},"skip":{"?0":0.967741935483871,"*MISSING_VALUE*":0.03225806451612903}},"linux":{"deeper":{"chrome/147.0.7727.116":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"*MISSING_VALUE*":{"deeper":{"chrome/147.0.0.0":{"?0":1}},"skip":{"?0":1}}},"skip":{"deeper":{"chrome/147.0.0.0":{"?0":1},"edge/147.0.0.0":{"?0":0.9444444444444444,"*MISSING_VALUE*":0.05555555555555555},"safari/26.4":{"*MISSING_VALUE*":1},"chrome/146.0.0.0":{"?0":1},"safari/18.5":{"*MISSING_VALUE*":1},"chrome/142.0.0.0":{"?0":1},"chrome/145.0.0.0":{"?0":1},"chrome/143.0.0.0":{"?0":1},"chrome/116.0.0.0":{"*MISSING_VALUE*":1},"edge/146.0.0.0":{"?0":1},"chrome/96.0.4664.110":{"*MISSING_VALUE*":1},"chrome/147.0.7727.116":{"*MISSING_VALUE*":1}},"skip":{"?0":0.9099099099099099,"*MISSING_VALUE*":0.09009009009009009}}}}}}},{"name":"sec-ch-ua-platform","parentNames":["*HTTP_VERSION","*DEVICE","*OPERATING_SYSTEM","*BROWSER"],"possibleValues":["\"macOS\"","\"Windows\"","*MISSING_VALUE*","\"Android\"","\"iOS\"","\"Linux\"","\"Chrome OS\""],"conditionalProbabilities":{"deeper":{"_2.0_":{"deeper":{"desktop":{"deeper":{"macos":{"deeper":{"chrome/147.0.0.0":{"\"macOS\"":0.9977548271216884,"*MISSING_VALUE*":0.00224517287831163},"edge/147.0.0.0":{"\"macOS\"":1},"safari/26.4":{"*MISSING_VALUE*":1},"safari/26.3":{"*MISSING_VALUE*":1},"chrome/146.0.0.0":{"\"macOS\"":1},"firefox/150.0":{"*MISSING_VALUE*":1},"safari/26.2":{"*MISSING_VALUE*":1},"safari/18.5":{"*MISSING_VALUE*":1},"safari/26.0.1":{"*MISSING_VALUE*":1},"firefox/135.0":{"*MISSING_VALUE*":1},"chrome/148.0.0.0":{"\"macOS\"":1},"chrome/142.0.0.0":{"\"macOS\"":1},"safari/18.3":{"*MISSING_VALUE*":1},"chrome/144.0.0.0":{"\"macOS\"":1},"safari/18.2":{"*MISSING_VALUE*":1},"safari/26.3.1":{"*MISSING_VALUE*":1},"safari/18.1":{"*MISSING_VALUE*":1},"safari/18.6":{"*MISSING_VALUE*":1},"chrome/145.0.0.0":{"\"macOS\"":1},"chrome/127.0.0.0":{"\"macOS\"":1},"safari/16.6":{"*MISSING_VALUE*":1},"chrome/141.0.0.0":{"\"macOS\"":1},"chrome/143.0.0.0":{"\"macOS\"":1},"chrome/128.0.0.0":{"\"macOS\"":1},"safari/17.2.1":{"*MISSING_VALUE*":1},"safari/26.5":{"*MISSING_VALUE*":1},"chrome/116.0.0.0":{"\"macOS\"":1},"chrome/138.0.0.0":{"\"macOS\"":1},"safari/26.1":{"*MISSING_VALUE*":1},"safari/17.6":{"*MISSING_VALUE*":1},"firefox/151.0":{"*MISSING_VALUE*":1},"chrome/130.0.0.0":{"\"macOS\"":1},"chrome/115.0.0.0":{"\"macOS\"":1},"chrome/149.0.0.0":{"\"macOS\"":1},"chrome/140.0.0.0":{"\"macOS\"":1},"safari/16.4":{"*MISSING_VALUE*":1},"safari/18.1.1":{"*MISSING_VALUE*":1},"chrome/139.0.0.0":{"\"macOS\"":1},"safari/17.4":{"*MISSING_VALUE*":1},"chrome/131.0.0.0":{"\"macOS\"":1},"safari/18.4":{"*MISSING_VALUE*":1},"safari/18.0.1":{"*MISSING_VALUE*":1},"safari/16.5.2":{"*MISSING_VALUE*":1},"safari/26.0":{"*MISSING_VALUE*":1},"safari/16.6.1":{"*MISSING_VALUE*":1},"firefox/147.0":{"*MISSING_VALUE*":1},"chrome/135.0.0.0":{"\"macOS\"":1},"safari/17.5":{"*MISSING_VALUE*":1},"chrome/91.0.4450.0":{"*MISSING_VALUE*":1},"safari/17.4.1":{"*MISSING_VALUE*":1},"safari/18.3.1":{"*MISSING_VALUE*":1},"chrome/114.0.0.0":{"*MISSING_VALUE*":1},"safari/16.5":{"*MISSING_VALUE*":1},"chrome/138.0.7204.235":{"*MISSING_VALUE*":1},"safari/17.3":{"*MISSING_VALUE*":1},"firefox/149.0":{"*MISSING_VALUE*":1},"safari/15.6.8":{"*MISSING_VALUE*":1},"safari/17.3.1":{"*MISSING_VALUE*":1},"chrome/107.0.0.0":{"\"Linux\"":1},"chrome/103.0.0.0":{"\"macOS\"":1},"safari/17.1.2":{"*MISSING_VALUE*":1},"safari/18.0":{"*MISSING_VALUE*":1},"safari/17.0":{"*MISSING_VALUE*":1},"safari/17.1":{"*MISSING_VALUE*":1},"safari/15.6.1":{"*MISSING_VALUE*":1},"safari/17.2":{"*MISSING_VALUE*":1},"safari/16.1":{"*MISSING_VALUE*":1},"safari/16.3":{"*MISSING_VALUE*":1}},"skip":{"\"macOS\"":0.6792127921279213,"*MISSING_VALUE*":0.3202952029520295,"\"Linux\"":0.0004920049200492004}},"windows":{"deeper":{"chrome/147.0.0.0":{"\"Windows\"":1},"edge/147.0.0.0":{"\"Windows\"":1},"chrome/146.0.0.0":{"\"Windows\"":0.9910714285714286,"*MISSING_VALUE*":0.008928571428571428},"firefox/150.0":{"*MISSING_VALUE*":1},"firefox/135.0":{"*MISSING_VALUE*":0.9473684210526315,"\"Windows\"":0.05263157894736842},"chrome/148.0.0.0":{"\"Windows\"":1},"chrome/142.0.0.0":{"\"Windows\"":1},"chrome/144.0.0.0":{"\"Windows\"":1},"edge/129.0.0.0":{"\"Windows\"":1},"chrome/145.0.0.0":{"\"Windows\"":1},"chrome/127.0.0.0":{"\"Windows\"":1},"chrome/141.0.0.0":{"\"Windows\"":1},"chrome/121.0.0.0":{"\"Windows\"":1},"chrome/143.0.0.0":{"\"Windows\"":1},"chrome/128.0.0.0":{"\"Windows\"":1},"chrome/138.0.0.0":{"\"Windows\"":1},"chrome/130.0.0.0":{"\"Windows\"":1},"edge/146.0.0.0":{"\"Windows\"":1},"chrome/149.0.0.0":{"\"Windows\"":1},"chrome/140.0.0.0":{"\"Windows\"":1},"edge/144.0.0.0":{"\"Windows\"":1},"edge/128.0.0.0":{"\"Windows\"":1},"chrome/109.0.0.0":{"\"Windows\"":1},"chrome/139.0.0.0":{"\"Windows\"":1},"edge/143.0.0.0":{"\"Windows\"":1},"chrome/131.0.0.0":{"\"Windows\"":1},"chrome/134.0.0.0":{"\"Windows\"":1},"chrome/120.0.0.0":{"\"Windows\"":1},"chrome/137.0.0.0":{"\"Windows\"":1},"firefox/147.0":{"*MISSING_VALUE*":1},"edge/145.0.0.0":{"\"Windows\"":1},"chrome/135.0.0.0":{"\"Windows\"":1},"chrome/125.0.0.0":{"\"Windows\"":1},"chrome/126.0.0.0":{"\"Windows\"":1},"chrome/113.0.0.0":{"\"Windows\"":1},"chrome/108.0.0.0":{"\"Windows\"":1},"edge/148.0.0.0":{"\"Windows\"":1},"chrome/147.0.7727.56":{"\"Windows\"":1},"edge/123.0.0.0":{"\"Windows\"":1},"chrome/136.0.0.0":{"\"Windows\"":1},"edge/138.0.0.0":{"\"Windows\"":1},"edge/142.0.0.0":{"\"Windows\"":1},"firefox/149.0":{"*MISSING_VALUE*":1},"chrome/124.0.0.0":{"\"Windows\"":1},"chrome/122.0.0.0":{"\"Windows\"":1},"edge/122.0.0.0":{"\"Windows\"":1},"chrome/132.0.0.0":{"\"Windows\"":1},"chrome/133.0.0.0":{"\"Windows\"":1},"edge/135.0.3179.54":{"\"Windows\"":1},"chrome/106.0.0.0":{"\"Windows\"":1},"chrome/91.0.4472.124":{"*MISSING_VALUE*":1},"chrome/132.0.6788.76":{"\"Windows\"":1}},"skip":{"\"Windows\"":0.9953113729261842,"*MISSING_VALUE*":0.004688627073815821}},"linux":{"deeper":{"chrome/147.0.0.0":{"\"Linux\"":1},"edge/147.0.0.0":{"\"Linux\"":1},"chrome/146.0.0.0":{"\"Linux\"":1},"firefox/150.0":{"*MISSING_VALUE*":1},"chrome/148.0.0.0":{"\"Linux\"":1},"chrome/142.0.0.0":{"\"Linux\"":1},"chrome/144.0.0.0":{"\"Linux\"":1},"chrome/145.0.0.0":{"\"Linux\"":1},"chrome/141.0.0.0":{"\"Linux\"":1},"chrome/143.0.0.0":{"\"Linux\"":1},"chrome/138.0.0.0":{"\"Linux\"":1},"chrome/130.0.0.0":{"\"Linux\"":1},"chrome/149.0.0.0":{"\"Linux\"":1},"chrome/140.0.0.0":{"\"Linux\"":1},"edge/143.0.0.0":{"\"Linux\"":1},"chrome/131.0.0.0":{"\"Linux\"":1},"chrome/134.0.0.0":{"\"Linux\"":1},"chrome/137.0.0.0":{"\"Linux\"":1},"edge/145.0.0.0":{"\"Linux\"":1},"chrome/135.0.0.0":{"\"Linux\"":1},"chrome/126.0.0.0":{"\"Linux\"":1},"chrome/101.0.4951.54":{"*MISSING_VALUE*":1},"chrome/136.0.0.0":{"\"Linux\"":1},"firefox/149.0":{"*MISSING_VALUE*":1},"firefox/146.0":{"*MISSING_VALUE*":1},"chrome/90.0.4430.212":{"*MISSING_VALUE*":1},"firefox/142.0":{"*MISSING_VALUE*":1}},"skip":{"\"Linux\"":0.9065420560747663,"*MISSING_VALUE*":0.09345794392523364}},"*MISSING_VALUE*":{"deeper":{"chrome/147.0.0.0":{"\"Chrome OS\"":1},"chrome/146.0.0.0":{"\"Chrome OS\"":1},"chrome/144.0.0.0":{"\"Chrome OS\"":1},"chrome/126.0.0.0":{"\"Chrome OS\"":1}},"skip":{"\"Chrome OS\"":1}}},"skip":{"deeper":{"chrome/147.0.0.0":{"\"macOS\"":0.2438274991769999,"\"Windows\"":0.734993964665862,"\"Linux\"":0.01975200263360035,"\"Chrome OS\"":0.0008778667837155712,"*MISSING_VALUE*":0.000548666739822232},"edge/147.0.0.0":{"\"Windows\"":0.9763694951664876,"\"macOS\"":0.022556390977443608,"\"Linux\"":0.0010741138560687433},"safari/26.4":{"*MISSING_VALUE*":1},"safari/26.3":{"*MISSING_VALUE*":1},"chrome/146.0.0.0":{"\"macOS\"":0.6407322654462243,"\"Linux\"":0.09839816933638444,"\"Windows\"":0.2540045766590389,"*MISSING_VALUE*":0.002288329519450801,"\"Chrome OS\"":0.004576659038901602},"firefox/150.0":{"*MISSING_VALUE*":1},"safari/26.2":{"*MISSING_VALUE*":1},"safari/18.5":{"*MISSING_VALUE*":1},"safari/26.0.1":{"*MISSING_VALUE*":1},"firefox/135.0":{"*MISSING_VALUE*":0.95,"\"Windows\"":0.05},"chrome/148.0.0.0":{"\"Windows\"":0.7142857142857143,"\"Linux\"":0.02040816326530612,"\"macOS\"":0.2653061224489796},"chrome/142.0.0.0":{"\"Windows\"":0.944954128440367,"\"Linux\"":0.01834862385321101,"\"macOS\"":0.03669724770642202},"safari/18.3":{"*MISSING_VALUE*":1},"chrome/144.0.0.0":{"\"macOS\"":0.46153846153846156,"\"Windows\"":0.3269230769230769,"\"Linux\"":0.19230769230769232,"\"Chrome OS\"":0.019230769230769232},"safari/18.2":{"*MISSING_VALUE*":1},"safari/26.3.1":{"*MISSING_VALUE*":1},"edge/129.0.0.0":{"\"Windows\"":1},"safari/18.1":{"*MISSING_VALUE*":1},"safari/18.6":{"*MISSING_VALUE*":1},"chrome/145.0.0.0":{"\"Linux\"":0.09523809523809523,"\"Windows\"":0.46320346320346323,"\"macOS\"":0.44155844155844154},"chrome/127.0.0.0":{"\"Windows\"":0.3333333333333333,"\"macOS\"":0.6666666666666666},"safari/16.6":{"*MISSING_VALUE*":1},"chrome/141.0.0.0":{"\"Windows\"":0.75,"\"macOS\"":0.2,"\"Linux\"":0.05},"chrome/121.0.0.0":{"\"Windows\"":1},"chrome/143.0.0.0":{"\"macOS\"":0.4716981132075472,"\"Windows\"":0.4339622641509434,"\"Linux\"":0.09433962264150944},"chrome/128.0.0.0":{"\"macOS\"":0.625,"\"Windows\"":0.375},"safari/17.2.1":{"*MISSING_VALUE*":1},"safari/26.5":{"*MISSING_VALUE*":1},"chrome/116.0.0.0":{"\"macOS\"":1},"chrome/138.0.0.0":{"\"macOS\"":0.5957446808510638,"\"Windows\"":0.3404255319148936,"\"Linux\"":0.06382978723404255},"safari/26.1":{"*MISSING_VALUE*":1},"safari/17.6":{"*MISSING_VALUE*":1},"firefox/151.0":{"*MISSING_VALUE*":1},"chrome/130.0.0.0":{"\"Linux\"":0.25,"\"Windows\"":0.5,"\"macOS\"":0.25},"chrome/115.0.0.0":{"\"macOS\"":1},"edge/146.0.0.0":{"\"Windows\"":1},"chrome/149.0.0.0":{"\"macOS\"":0.18181818181818182,"\"Windows\"":0.6363636363636364,"\"Linux\"":0.18181818181818182},"chrome/140.0.0.0":{"\"Windows\"":0.5714285714285714,"\"Linux\"":0.21428571428571427,"\"macOS\"":0.21428571428571427},"safari/16.4":{"*MISSING_VALUE*":1},"safari/18.1.1":{"*MISSING_VALUE*":1},"edge/144.0.0.0":{"\"Windows\"":1},"edge/128.0.0.0":{"\"Windows\"":1},"chrome/109.0.0.0":{"\"Windows\"":1},"chrome/139.0.0.0":{"\"Windows\"":0.8461538461538461,"\"macOS\"":0.15384615384615385},"edge/143.0.0.0":{"\"Windows\"":0.8888888888888888,"\"Linux\"":0.1111111111111111},"safari/17.4":{"*MISSING_VALUE*":1},"chrome/131.0.0.0":{"\"Windows\"":0.36363636363636365,"\"Linux\"":0.45454545454545453,"\"macOS\"":0.18181818181818182},"safari/18.4":{"*MISSING_VALUE*":1},"safari/18.0.1":{"*MISSING_VALUE*":1},"chrome/134.0.0.0":{"\"Windows\"":0.75,"\"Linux\"":0.25},"safari/16.5.2":{"*MISSING_VALUE*":1},"chrome/120.0.0.0":{"\"Windows\"":1},"chrome/137.0.0.0":{"\"Windows\"":0.8,"\"Linux\"":0.2},"safari/26.0":{"*MISSING_VALUE*":1},"safari/16.6.1":{"*MISSING_VALUE*":1},"firefox/147.0":{"*MISSING_VALUE*":1},"edge/145.0.0.0":{"\"Windows\"":0.8,"\"Linux\"":0.2},"chrome/135.0.0.0":{"\"Windows\"":0.8181818181818182,"\"macOS\"":0.09090909090909091,"\"Linux\"":0.09090909090909091},"safari/17.5":{"*MISSING_VALUE*":1},"chrome/91.0.4450.0":{"*MISSING_VALUE*":1},"chrome/125.0.0.0":{"\"Windows\"":1},"chrome/126.0.0.0":{"\"Windows\"":0.5555555555555556,"\"Linux\"":0.2222222222222222,"\"Chrome OS\"":0.2222222222222222},"chrome/113.0.0.0":{"\"Windows\"":1},"chrome/101.0.4951.54":{"*MISSING_VALUE*":1},"chrome/108.0.0.0":{"\"Windows\"":1},"edge/148.0.0.0":{"\"Windows\"":1},"chrome/147.0.7727.56":{"\"Windows\"":1},"safari/17.4.1":{"*MISSING_VALUE*":1},"edge/123.0.0.0":{"\"Windows\"":1},"chrome/136.0.0.0":{"\"Windows\"":0.8333333333333334,"\"Linux\"":0.16666666666666666},"safari/18.3.1":{"*MISSING_VALUE*":1},"edge/138.0.0.0":{"\"Windows\"":1},"chrome/114.0.0.0":{"*MISSING_VALUE*":1},"safari/16.5":{"*MISSING_VALUE*":1},"chrome/138.0.7204.235":{"*MISSING_VALUE*":1},"safari/17.3":{"*MISSING_VALUE*":1},"edge/142.0.0.0":{"\"Windows\"":1},"firefox/149.0":{"*MISSING_VALUE*":1},"safari/15.6.8":{"*MISSING_VALUE*":1},"safari/17.3.1":{"*MISSING_VALUE*":1},"chrome/124.0.0.0":{"\"Windows\"":1},"chrome/122.0.0.0":{"\"Windows\"":1},"chrome/107.0.0.0":{"\"Linux\"":1},"firefox/146.0":{"*MISSING_VALUE*":1},"edge/122.0.0.0":{"\"Windows\"":1},"chrome/103.0.0.0":{"\"macOS\"":1},"chrome/90.0.4430.212":{"*MISSING_VALUE*":1},"safari/17.1.2":{"*MISSING_VALUE*":1},"chrome/132.0.0.0":{"\"Windows\"":1},"safari/18.0":{"*MISSING_VALUE*":1},"chrome/133.0.0.0":{"\"Windows\"":1},"firefox/142.0":{"*MISSING_VALUE*":1},"safari/17.0":{"*MISSING_VALUE*":1},"safari/17.1":{"*MISSING_VALUE*":1},"safari/15.6.1":{"*MISSING_VALUE*":1},"safari/17.2":{"*MISSING_VALUE*":1},"edge/135.0.3179.54":{"\"Windows\"":1},"chrome/106.0.0.0":{"\"Windows\"":1},"safari/16.1":{"*MISSING_VALUE*":1},"chrome/91.0.4472.124":{"*MISSING_VALUE*":1},"chrome/132.0.6788.76":{"\"Windows\"":1},"safari/16.3":{"*MISSING_VALUE*":1}},"skip":{"\"macOS\"":0.21711095384131476,"\"Windows\"":0.6510183219312731,"*MISSING_VALUE*":0.10780844538806322,"\"Linux\"":0.023040025163167412,"\"Chrome OS\"":0.0010222536761814894}}},"mobile":{"deeper":{"ios":{"deeper":{"safari/26.4":{"*MISSING_VALUE*":0.9964285714285714,"\"iOS\"":0.0035714285714285713},"safari/26.3":{"*MISSING_VALUE*":1},"safari/26.2":{"*MISSING_VALUE*":1},"safari/18.5":{"\"iOS\"":0.07692307692307693,"*MISSING_VALUE*":0.9230769230769231},"safari/26.0.1":{"*MISSING_VALUE*":1},"safari/18.3":{"*MISSING_VALUE*":1},"safari/18.2":{"*MISSING_VALUE*":1},"safari/26.3.1":{"*MISSING_VALUE*":1},"safari/18.7":{"*MISSING_VALUE*":1},"safari/18.7.4":{"*MISSING_VALUE*":1},"safari/18.6":{"*MISSING_VALUE*":1},"safari/18.7.5":{"*MISSING_VALUE*":1},"safari/26.5":{"*MISSING_VALUE*":1},"safari/26.1":{"*MISSING_VALUE*":1},"safari/17.6":{"*MISSING_VALUE*":1},"safari/18.4":{"*MISSING_VALUE*":1},"safari/26.4.2":{"*MISSING_VALUE*":1},"safari/26.0":{"*MISSING_VALUE*":1},"safari/16.6.1":{"*MISSING_VALUE*":1},"safari/17.5":{"*MISSING_VALUE*":1},"chrome/45.0.8909.1591":{"*MISSING_VALUE*":1},"safari/17.7":{"*MISSING_VALUE*":1},"safari/17.4.1":{"*MISSING_VALUE*":1},"safari/16.6.2":{"*MISSING_VALUE*":1},"safari/18.3.1":{"*MISSING_VALUE*":1},"safari/16.5":{"*MISSING_VALUE*":1},"chrome/144.0.7559.95":{"*MISSING_VALUE*":1},"safari/18.7.3":{"*MISSING_VALUE*":1},"safari/17.8":{"*MISSING_VALUE*":1},"safari/15.6.8":{"*MISSING_VALUE*":1},"safari/15.6.7":{"*MISSING_VALUE*":1},"safari/16.2":{"*MISSING_VALUE*":1},"safari/18.0":{"*MISSING_VALUE*":1},"safari/18.7.7":{"*MISSING_VALUE*":1},"safari/18.7.2":{"*MISSING_VALUE*":1},"safari/18.4.1":{"*MISSING_VALUE*":1},"safari/26.4.1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":0.9980638915779284,"\"iOS\"":0.001936108422071636}},"android":{"deeper":{"chrome/147.0.0.0":{"\"Android\"":1},"edge/147.0.0.0":{"\"Android\"":1},"chrome/146.0.0.0":{"\"Android\"":1},"firefox/150.0":{"*MISSING_VALUE*":1},"chrome/148.0.0.0":{"\"Android\"":1},"chrome/144.0.0.0":{"\"Android\"":1},"chrome/145.0.0.0":{"\"Android\"":1},"chrome/143.0.0.0":{"\"Android\"":1},"chrome/138.0.0.0":{"\"Android\"":1},"edge/143.0.0.0":{"\"Android\"":1},"chrome/134.0.0.0":{"\"Android\"":1},"chrome/120.0.0.0":{"\"Android\"":1},"chrome/137.0.0.0":{"\"Android\"":1},"chrome/119.0.0.0":{"\"Android\"":1},"chrome/142.0.7444.138":{"\"Android\"":1},"firefox/136.0":{"*MISSING_VALUE*":1},"chrome/59.0.9273.1293":{"*MISSING_VALUE*":1},"chrome/138.0.7204.63":{"*MISSING_VALUE*":1},"chrome/147.0.7727.111":{"\"Android\"":0.16666666666666666,"*MISSING_VALUE*":0.8333333333333334},"chrome/130.0.6723.73":{"\"Android\"":1}},"skip":{"\"Android\"":0.9425981873111783,"*MISSING_VALUE*":0.05740181268882175}}},"skip":{"deeper":{"chrome/147.0.0.0":{"\"Android\"":1},"edge/147.0.0.0":{"\"Android\"":1},"safari/26.4":{"*MISSING_VALUE*":0.9964285714285714,"\"iOS\"":0.0035714285714285713},"safari/26.3":{"*MISSING_VALUE*":1},"chrome/146.0.0.0":{"\"Android\"":1},"firefox/150.0":{"*MISSING_VALUE*":1},"safari/26.2":{"*MISSING_VALUE*":1},"safari/18.5":{"\"iOS\"":0.07692307692307693,"*MISSING_VALUE*":0.9230769230769231},"safari/26.0.1":{"*MISSING_VALUE*":1},"chrome/148.0.0.0":{"\"Android\"":1},"safari/18.3":{"*MISSING_VALUE*":1},"chrome/144.0.0.0":{"\"Android\"":1},"safari/18.2":{"*MISSING_VALUE*":1},"safari/26.3.1":{"*MISSING_VALUE*":1},"safari/18.7":{"*MISSING_VALUE*":1},"safari/18.7.4":{"*MISSING_VALUE*":1},"safari/18.6":{"*MISSING_VALUE*":1},"safari/18.7.5":{"*MISSING_VALUE*":1},"chrome/145.0.0.0":{"\"Android\"":1},"chrome/143.0.0.0":{"\"Android\"":1},"safari/26.5":{"*MISSING_VALUE*":1},"chrome/138.0.0.0":{"\"Android\"":1},"safari/26.1":{"*MISSING_VALUE*":1},"safari/17.6":{"*MISSING_VALUE*":1},"edge/143.0.0.0":{"\"Android\"":1},"safari/18.4":{"*MISSING_VALUE*":1},"chrome/134.0.0.0":{"\"Android\"":1},"safari/26.4.2":{"*MISSING_VALUE*":1},"chrome/120.0.0.0":{"\"Android\"":1},"chrome/137.0.0.0":{"\"Android\"":1},"safari/26.0":{"*MISSING_VALUE*":1},"safari/16.6.1":{"*MISSING_VALUE*":1},"safari/17.5":{"*MISSING_VALUE*":1},"chrome/45.0.8909.1591":{"*MISSING_VALUE*":1},"safari/17.7":{"*MISSING_VALUE*":1},"safari/17.4.1":{"*MISSING_VALUE*":1},"safari/16.6.2":{"*MISSING_VALUE*":1},"chrome/119.0.0.0":{"\"Android\"":1},"chrome/142.0.7444.138":{"\"Android\"":1},"safari/18.3.1":{"*MISSING_VALUE*":1},"firefox/136.0":{"*MISSING_VALUE*":1},"safari/16.5":{"*MISSING_VALUE*":1},"chrome/59.0.9273.1293":{"*MISSING_VALUE*":1},"chrome/144.0.7559.95":{"*MISSING_VALUE*":1},"safari/18.7.3":{"*MISSING_VALUE*":1},"chrome/138.0.7204.63":{"*MISSING_VALUE*":1},"safari/17.8":{"*MISSING_VALUE*":1},"safari/15.6.8":{"*MISSING_VALUE*":1},"chrome/147.0.7727.111":{"\"Android\"":0.16666666666666666,"*MISSING_VALUE*":0.8333333333333334},"safari/15.6.7":{"*MISSING_VALUE*":1},"safari/16.2":{"*MISSING_VALUE*":1},"safari/18.0":{"*MISSING_VALUE*":1},"chrome/130.0.6723.73":{"\"Android\"":1},"safari/18.7.7":{"*MISSING_VALUE*":1},"safari/18.7.2":{"*MISSING_VALUE*":1},"safari/18.4.1":{"*MISSING_VALUE*":1},"safari/26.4.1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":0.7697947214076246,"\"Android\"":0.2287390029325513,"\"iOS\"":0.001466275659824047}}}}},"_1.1_":{"deeper":{"desktop":{"deeper":{"macos":{"deeper":{"chrome/147.0.0.0":{"\"macOS\"":1},"safari/26.4":{"*MISSING_VALUE*":1},"safari/18.5":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":0.23076923076923078,"\"macOS\"":0.7692307692307693}},"windows":{"deeper":{"chrome/147.0.0.0":{"\"Windows\"":1},"edge/147.0.0.0":{"\"Windows\"":0.9444444444444444,"*MISSING_VALUE*":0.05555555555555555},"chrome/146.0.0.0":{"\"Windows\"":1},"chrome/142.0.0.0":{"\"Windows\"":1},"chrome/145.0.0.0":{"\"Windows\"":1},"chrome/143.0.0.0":{"\"Windows\"":1},"chrome/116.0.0.0":{"*MISSING_VALUE*":1},"edge/146.0.0.0":{"\"Windows\"":1},"chrome/96.0.4664.110":{"*MISSING_VALUE*":1}},"skip":{"\"Windows\"":0.967741935483871,"*MISSING_VALUE*":0.03225806451612903}},"linux":{"deeper":{"chrome/147.0.7727.116":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"*MISSING_VALUE*":{"deeper":{"chrome/147.0.0.0":{"\"Chrome OS\"":1}},"skip":{"\"Chrome OS\"":1}}},"skip":{"deeper":{"chrome/147.0.0.0":{"\"Windows\"":0.8405797101449275,"\"Chrome OS\"":0.014492753623188406,"\"macOS\"":0.14492753623188406},"edge/147.0.0.0":{"\"Windows\"":0.9444444444444444,"*MISSING_VALUE*":0.05555555555555555},"safari/26.4":{"*MISSING_VALUE*":1},"chrome/146.0.0.0":{"\"Windows\"":1},"safari/18.5":{"*MISSING_VALUE*":1},"chrome/142.0.0.0":{"\"Windows\"":1},"chrome/145.0.0.0":{"\"Windows\"":1},"chrome/143.0.0.0":{"\"Windows\"":1},"chrome/116.0.0.0":{"*MISSING_VALUE*":1},"edge/146.0.0.0":{"\"Windows\"":1},"chrome/96.0.4664.110":{"*MISSING_VALUE*":1},"chrome/147.0.7727.116":{"*MISSING_VALUE*":1}},"skip":{"\"Windows\"":0.8108108108108109,"\"Chrome OS\"":0.009009009009009009,"*MISSING_VALUE*":0.09009009009009009,"\"macOS\"":0.09009009009009009}}}},"skip":{"deeper":{"macos":{"deeper":{"chrome/147.0.0.0":{"\"macOS\"":1},"safari/26.4":{"*MISSING_VALUE*":1},"safari/18.5":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":0.23076923076923078,"\"macOS\"":0.7692307692307693}},"windows":{"deeper":{"chrome/147.0.0.0":{"\"Windows\"":1},"edge/147.0.0.0":{"\"Windows\"":0.9444444444444444,"*MISSING_VALUE*":0.05555555555555555},"chrome/146.0.0.0":{"\"Windows\"":1},"chrome/142.0.0.0":{"\"Windows\"":1},"chrome/145.0.0.0":{"\"Windows\"":1},"chrome/143.0.0.0":{"\"Windows\"":1},"chrome/116.0.0.0":{"*MISSING_VALUE*":1},"edge/146.0.0.0":{"\"Windows\"":1},"chrome/96.0.4664.110":{"*MISSING_VALUE*":1}},"skip":{"\"Windows\"":0.967741935483871,"*MISSING_VALUE*":0.03225806451612903}},"linux":{"deeper":{"chrome/147.0.7727.116":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"*MISSING_VALUE*":{"deeper":{"chrome/147.0.0.0":{"\"Chrome OS\"":1}},"skip":{"\"Chrome OS\"":1}}},"skip":{"deeper":{"chrome/147.0.0.0":{"\"Windows\"":0.8405797101449275,"\"Chrome OS\"":0.014492753623188406,"\"macOS\"":0.14492753623188406},"edge/147.0.0.0":{"\"Windows\"":0.9444444444444444,"*MISSING_VALUE*":0.05555555555555555},"safari/26.4":{"*MISSING_VALUE*":1},"chrome/146.0.0.0":{"\"Windows\"":1},"safari/18.5":{"*MISSING_VALUE*":1},"chrome/142.0.0.0":{"\"Windows\"":1},"chrome/145.0.0.0":{"\"Windows\"":1},"chrome/143.0.0.0":{"\"Windows\"":1},"chrome/116.0.0.0":{"*MISSING_VALUE*":1},"edge/146.0.0.0":{"\"Windows\"":1},"chrome/96.0.4664.110":{"*MISSING_VALUE*":1},"chrome/147.0.7727.116":{"*MISSING_VALUE*":1}},"skip":{"\"Windows\"":0.8108108108108109,"\"Chrome OS\"":0.009009009009009009,"*MISSING_VALUE*":0.09009009009009009,"\"macOS\"":0.09009009009009009}}}}}}},{"name":"user-agent","parentNames":["*HTTP_VERSION","*DEVICE","*OPERATING_SYSTEM","*BROWSER","sec-ch-ua-mobile"],"possibleValues":["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15","*MISSING_VALUE*","Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15","Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1","Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15","Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0","Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.4 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15","Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15","Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.5 Mobile/15E148 Safari/604.1","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36","Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15","Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1 Brave","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15","Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15","Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15","Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Safari/605.1.15","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0","Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15","Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Safari/605.1.15","Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 Edg/147.0.0.0","Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 Brave","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0","Mozilla/5.0 (Android 16; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0","Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 EdgA/147.0.0.0","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15","Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.8909.1591 Mobile Safari/537.36","Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4450.0 Safari/537.36 LarkUrl","Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36 EdgA/143.0.0.0","Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1","Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 GrokApp/1.3.71","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36","Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0","Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.56 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15","Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_15 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.2 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36","Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.138 Mobile Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15","Mozilla/5.0 (Android 13; Mobile; rv:136.0) Gecko/136.0 Firefox/136.0","Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.235 Safari/537.36","Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.9273.1293 Mobile Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 26_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/144.0.7559.95 Mobile/15E148 Safari/604.1","Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/23D127 Safari/604.1","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0","Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.3 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36","Mozilla/5.0 (Linux; Android 12; X16DzOXpOQ; U; en) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.63 Mobile Safari/537.36","Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0","Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1","Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Safari/605.1.15","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15","Mozilla/5.0 (Linux; Android 15; SM-G991W Build/AP3A.240905.015.A2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 CCleaner/146.0.34394.179","Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.7 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36","Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0","Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Mobile Safari/537.36","Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_14 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15","Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1","Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 26_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36","Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36","Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1","Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1 Brave","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15","Mozilla/5.0 (Linux; Android 15; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.73 Mobile Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/23D8133 Safari/604.1","Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1","Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Mobile/15E148 Safari/604.1","Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/23E261 Safari/604.1","Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36","Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36","Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36","Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15","Mozilla/5.0 (iPad; CPU OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1 Brave","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36","Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.7 Mobile/22H340 Safari/604.1","Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1","Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.2 Mobile/15E148 Safari/604.1","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_8) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15","Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4.1 Mobile/22E252 Safari/604.1","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.3179.54","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36","Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36","Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0","Mozilla/5.0 (Linux; Android 16; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1","Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1","Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Mobile/15E148 Safari/604.1","Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1","Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.1 Mobile/15E148 Safari/604.1","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Atom/26.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36","Mozilla/5.0 (iPhone; CPU iPhone OS 26_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6788.76 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15","Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"],"conditionalProbabilities":{"deeper":{"_2.0_":{"deeper":{"desktop":{"deeper":{"macos":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1},"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"edge/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":1}},"safari/26.4":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":1}},"safari/26.3":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15":1}},"chrome/146.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":1}},"firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0":1}},"safari/26.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15":1}},"safari/18.5":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":1}},"safari/26.0.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15":1}},"firefox/135.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0":1}},"chrome/148.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":1}},"chrome/142.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":1}},"safari/18.3":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15":1}},"chrome/144.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":1}},"safari/18.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15":1}},"safari/26.3.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15":1}},"safari/18.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15":1}},"safari/18.6":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15":1}},"chrome/145.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":1}},"chrome/127.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":1}},"safari/16.6":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15":1}},"chrome/141.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":1}},"chrome/143.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":1}},"chrome/128.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":1}},"safari/17.2.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15":1}},"safari/26.5":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15":1}},"chrome/116.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":1}},"chrome/138.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":1}},"safari/26.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Safari/605.1.15":1}},"safari/17.6":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15":1}},"firefox/151.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0":1}},"chrome/130.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":1}},"chrome/115.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36":1}},"chrome/149.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":1}},"chrome/140.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":1}},"safari/16.4":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15":1}},"safari/18.1.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15":1}},"chrome/139.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":1}},"safari/17.4":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15":1}},"chrome/131.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":1}},"safari/18.4":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15":1}},"safari/18.0.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15":1}},"safari/16.5.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Safari/605.1.15":1}},"safari/26.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15":1}},"safari/16.6.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15":1}},"firefox/147.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0":1}},"chrome/135.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":1}},"safari/17.5":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15":1}},"chrome/91.0.4450.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4450.0 Safari/537.36 LarkUrl":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4450.0 Safari/537.36 LarkUrl":1}},"safari/17.4.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15":1}},"safari/18.3.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15":1}},"chrome/114.0.0.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36":1}},"safari/16.5":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15":1}},"chrome/138.0.7204.235":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.235 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.235 Safari/537.36":1}},"safari/17.3":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15":1}},"firefox/149.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0":1}},"safari/15.6.8":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Safari/605.1.15":1}},"safari/17.3.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15":1}},"chrome/107.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36":1}},"chrome/103.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36":1}},"safari/17.1.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15":1}},"safari/18.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15":1}},"safari/17.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15":1}},"safari/17.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15":1}},"safari/15.6.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_8) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_8) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15":1}},"safari/17.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15":1}},"safari/16.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15":1}},"safari/16.3":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15":1}}},"skip":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.8041983351429606,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.10133912414042708,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.008686210640608035,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.03691639522258415,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":0.009410061527325372,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.009048136083966703,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":0.0018096272167933405,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":0.0014477017734346724,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":0.010133912414042706,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36":0.0003619254433586681,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":0.0007238508867173362,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.00760043431053203,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":0.0014477017734346724,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36":0.0007238508867173362,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.0014477017734346724,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36":0.0003619254433586681,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":0.0007238508867173362,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":0.0007238508867173362,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":0.0010857763300760044,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":0.0003619254433586681,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":0.0007238508867173362,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":0.0007238508867173362},"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":0.2849462365591398,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0":0.017665130568356373,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15":0.08294930875576037,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15":0.08602150537634409,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15":0.020737327188940093,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15":0.014592933947772658,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15":0.0038402457757296467,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15":0.19738863287250383,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15":0.016897081413210446,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15":0.10215053763440861,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15":0.004608294930875576,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":0.028417818740399385,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15":0.0007680491551459293,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15":0.018433179723502304,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0":0.0015360983102918587,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15":0.020737327188940093,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15":0.0015360983102918587,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15":0.0038402457757296467,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Safari/605.1.15":0.016897081413210446,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15":0.002304147465437788,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15":0.0007680491551459293,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Safari/605.1.15":0.0007680491551459293,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15":0.013824884792626729,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15":0.0015360983102918587,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0":0.0015360983102918587,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15":0.009216589861751152,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4450.0 Safari/537.36 LarkUrl":0.002304147465437788,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15":0.0038402457757296467,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0":0.0007680491551459293,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15":0.002304147465437788,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.0038402457757296467,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36":0.009216589861751152,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15":0.0030721966205837174,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.235 Safari/537.36":0.0007680491551459293,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15":0.002304147465437788,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Safari/605.1.15":0.0007680491551459293,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15":0.0030721966205837174,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15":0.0030721966205837174,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15":0.0007680491551459293,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15":0.0030721966205837174,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15":0.0007680491551459293,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15":0.0015360983102918587,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0":0.0015360983102918587,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_8) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15":0.0007680491551459293,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15":0.0007680491551459293,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15":0.0007680491551459293,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15":0.0007680491551459293}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.5478474784747848,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":0.09126691266912669,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.06888068880688807,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0":0.005658056580565805,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15":0.026568265682656828,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15":0.02755227552275523,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15":0.006642066420664207,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15":0.004674046740467405,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.005904059040590406,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15":0.0012300123001230013,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15":0.06322263222632227,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15":0.005412054120541206,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15":0.032718327183271834,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.025092250922509225,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":0.0063960639606396065,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15":0.0014760147601476014,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":0.009102091020910209,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.006150061500615006,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":0.0012300123001230013,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15":0.0002460024600246002,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":0.0009840098400984009,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":0.006888068880688807,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15":0.005904059040590406,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0":0.0004920049200492004,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36":0.0002460024600246002,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15":0.006642066420664207,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":0.0004920049200492004,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15":0.0004920049200492004,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15":0.0012300123001230013,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Safari/605.1.15":0.005412054120541206,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15":0.0007380073800738007,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15":0.0002460024600246002,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.0051660516605166054,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Safari/605.1.15":0.0002460024600246002,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15":0.004428044280442804,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15":0.0004920049200492004,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0":0.0004920049200492004,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15":0.002952029520295203,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4450.0 Safari/537.36 LarkUrl":0.0007380073800738007,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15":0.0012300123001230013,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0":0.0002460024600246002,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15":0.0007380073800738007,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36":0.002952029520295203,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15":0.0009840098400984009,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.235 Safari/537.36":0.0002460024600246002,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":0.0009840098400984009,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15":0.0007380073800738007,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Safari/605.1.15":0.0002460024600246002,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15":0.0009840098400984009,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36":0.0004920049200492004,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.0009840098400984009,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15":0.0009840098400984009,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36":0.0002460024600246002,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15":0.0002460024600246002,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":0.0004920049200492004,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15":0.0009840098400984009,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":0.0004920049200492004,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":0.0007380073800738007,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15":0.0002460024600246002,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15":0.0004920049200492004,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":0.0002460024600246002,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0":0.0004920049200492004,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_8) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15":0.0002460024600246002,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15":0.0002460024600246002,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":0.0004920049200492004,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":0.0004920049200492004,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15":0.0002460024600246002,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15":0.0002460024600246002}}},"windows":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"edge/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":1}},"chrome/146.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.990990990990991,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 CCleaner/146.0.34394.179":0.009009009009009009},"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.9910714285714286,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 CCleaner/146.0.34394.179":0.008928571428571428}},"firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0":1}},"firefox/135.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0":1},"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0":1}},"chrome/148.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":1}},"chrome/142.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":1}},"chrome/144.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":1}},"edge/129.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0":1}},"chrome/145.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":1}},"chrome/127.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":1}},"chrome/141.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":1}},"chrome/121.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36":1}},"chrome/143.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":1}},"chrome/128.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":1}},"chrome/138.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":1}},"chrome/130.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":1}},"edge/146.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":1}},"chrome/149.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":1}},"chrome/140.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":1}},"edge/144.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0":1}},"edge/128.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0":1}},"chrome/109.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36":1}},"chrome/139.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":1}},"edge/143.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":1}},"chrome/131.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":1}},"chrome/134.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":1}},"chrome/120.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36":1}},"chrome/137.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":1}},"firefox/147.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0":1}},"edge/145.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":1}},"chrome/135.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":1}},"chrome/125.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36":1}},"chrome/126.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":1}},"chrome/113.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36":1}},"chrome/108.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36":1}},"edge/148.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0":1}},"chrome/147.0.7727.56":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.56 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.56 Safari/537.36":1}},"edge/123.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0":1}},"chrome/136.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":1}},"edge/138.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0":1}},"edge/142.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0":1}},"firefox/149.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0":1}},"chrome/124.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36":1}},"chrome/122.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36":1}},"edge/122.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0":1}},"chrome/132.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36":1}},"chrome/133.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36":1}},"edge/135.0.3179.54":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.3179.54":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.3179.54":1}},"chrome/106.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Atom/26.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Atom/26.0.0.0 Safari/537.36":1}},"chrome/91.0.4472.124":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36":1}},"chrome/132.0.6788.76":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6788.76 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6788.76 Safari/537.36":1}}},"skip":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.8090349075975359,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.10979586906631236,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":0.008455127430849136,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.012441116076820873,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0":0.0003623626041792487,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.013286628819905786,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":0.00012078753472641623,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.012924266215726538,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":0.0018118130208962435,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36":0.0004831501389056649,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":0.0030196883681604058,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":0.0019326005556226597,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":0.0009663002778113298,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0":0.00024157506945283246,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0":0.00012078753472641623,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36":0.0030196883681604058,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":0.0013286628819905786,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":0.0009663002778113298,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":0.0004831501389056649,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":0.0007247252083584974,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":0.0004831501389056649,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36":0.0003623626041792487,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":0.0009663002778113298,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":0.0004831501389056649,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":0.001087087812537746,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.0027781132987075735,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.002053388090349076,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36":0.0012078753472641623,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":0.0006039376736320811,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36":0.00012078753472641623,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36":0.00024157506945283246,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0":0.002174175625075492,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.56 Safari/537.36":0.0006039376736320811,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0":0.0003623626041792487,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":0.0006039376736320811,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":0.0008455127430849137,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0":0.00012078753472641623,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0":0.00012078753472641623,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0":0.00024157506945283246,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36":0.0003623626041792487,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36":0.0006039376736320811,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 CCleaner/146.0.34394.179":0.00012078753472641623,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0":0.00012078753472641623,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36":0.0003623626041792487,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36":0.0006039376736320811,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":0.0003623626041792487,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.3179.54":0.00012078753472641623,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Atom/26.0.0.0 Safari/537.36":0.00024157506945283246,"Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6788.76 Safari/537.36":0.00012078753472641623},"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0":0.46153846153846156,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.02564102564102564,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0":0.3076923076923077,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0":0.05128205128205128,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0":0.05128205128205128,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36":0.10256410256410256}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.805241644626112,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.10928107718201491,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0":0.0022842029333974513,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":0.008415484491464294,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.012382784323154605,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0":0.0003606636210627555,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.013344553979321952,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":0.00012022120702091849,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.012863669151238278,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":0.0018033181053137774,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36":0.00048088482808367395,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":0.0030055301755229622,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":0.0019235393123346958,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":0.0009617696561673479,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0":0.00024044241404183698,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0":0.00012022120702091849,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36":0.0030055301755229622,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":0.0013224332772301035,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":0.0009617696561673479,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":0.00048088482808367395,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":0.000721327242125511,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":0.00048088482808367395,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36":0.0003606636210627555,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":0.0009617696561673479,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":0.00048088482808367395,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":0.0010819908631882664,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.0027650877614811254,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.0020437605193556144,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36":0.0012022120702091848,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":0.0006011060351045924,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36":0.00012022120702091849,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36":0.00024044241404183698,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0":0.002163981726376533,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.56 Safari/537.36":0.0006011060351045924,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0":0.0003606636210627555,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":0.0006011060351045924,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":0.0008415484491464295,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0":0.00012022120702091849,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0":0.00024044241404183698,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0":0.001442654484251022,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36":0.0003606636210627555,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36":0.0006011060351045924,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 CCleaner/146.0.34394.179":0.00012022120702091849,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0":0.00012022120702091849,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0":0.00024044241404183698,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36":0.0003606636210627555,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36":0.0006011060351045924,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":0.0003606636210627555,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.3179.54":0.00012022120702091849,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Atom/26.0.0.0 Safari/537.36":0.00024044241404183698,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0":0.00024044241404183698,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36":0.00048088482808367395,"Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6788.76 Safari/537.36":0.00012022120702091849}}},"linux":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"edge/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":1}},"chrome/146.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":1}},"firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":0.8333333333333334,"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":0.16666666666666666}},"skip":{"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":0.8333333333333334,"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":0.16666666666666666}},"chrome/148.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":1}},"chrome/142.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":1}},"chrome/144.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":1}},"chrome/145.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":1}},"chrome/141.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":1}},"chrome/143.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":1}},"chrome/138.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":1}},"chrome/130.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":1}},"chrome/149.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":1}},"chrome/140.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":1}},"edge/143.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":1}},"chrome/131.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":1}},"chrome/134.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":1}},"chrome/137.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":1}},"edge/145.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":1}},"chrome/135.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":1}},"chrome/126.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":1}},"chrome/101.0.4951.54":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36":1}},"chrome/136.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":1}},"firefox/149.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0":1}},"firefox/146.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0":1}},"chrome/90.0.4430.212":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36":1}},"firefox/142.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0":1}}},"skip":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.6185567010309279,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.14776632302405499,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":0.006872852233676976,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.07560137457044673,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":0.006872852233676976,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":0.010309278350515464,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.01718213058419244,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.006872852233676976,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.03436426116838488,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":0.003436426116838488,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":0.006872852233676976,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":0.006872852233676976,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":0.003436426116838488,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":0.01718213058419244,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":0.006872852233676976,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":0.006872852233676976,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":0.003436426116838488,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":0.003436426116838488,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.003436426116838488,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":0.010309278350515464,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":0.003436426116838488},"*MISSING_VALUE*":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36":0.3333333333333333,"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":0.3333333333333333,"Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0":0.1,"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0":0.06666666666666667,"Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36":0.03333333333333333,"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":0.06666666666666667,"Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0":0.06666666666666667}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.5607476635514018,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.13395638629283488,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":0.006230529595015576,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.06853582554517133,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":0.006230529595015576,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":0.009345794392523364,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.01557632398753894,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.006230529595015576,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36":0.03115264797507788,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.03115264797507788,"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":0.03115264797507788,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":0.003115264797507788,"Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0":0.009345794392523364,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":0.006230529595015576,"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0":0.006230529595015576,"Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36":0.003115264797507788,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":0.006230529595015576,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":0.003115264797507788,"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":0.006230529595015576,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":0.01557632398753894,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":0.006230529595015576,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":0.006230529595015576,"Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0":0.006230529595015576,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":0.003115264797507788,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":0.003115264797507788,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.003115264797507788,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":0.009345794392523364,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":0.003115264797507788}}},"*MISSING_VALUE*":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"chrome/146.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":1}},"chrome/144.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":1}},"chrome/126.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":1}}},"skip":{"deeper":{"?0":{"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.6153846153846154,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.15384615384615385,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":0.15384615384615385,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.07692307692307693}},"skip":{"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.6153846153846154,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.15384615384615385,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":0.15384615384615385,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.07692307692307693}}}},"skip":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.24396135265700483,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.7353974527887571,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.019762845849802372,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.000878348704435661},"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.24437616591682212,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.734993964665862,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.01975200263360035,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.0008778667837155712}},"edge/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.9763694951664876,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.022556390977443608,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.0010741138560687433}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.9763694951664876,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.022556390977443608,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.0010741138560687433}},"safari/26.4":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":1}},"safari/26.3":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15":1}},"chrome/146.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.6422018348623854,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.09862385321100918,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.25229357798165136,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 CCleaner/146.0.34394.179":0.0022935779816513763,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.0045871559633027525},"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.6407322654462243,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.09839816933638444,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.2540045766590389,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 CCleaner/146.0.34394.179":0.002288329519450801,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.004576659038901602}},"firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0":0.48936170212765956,"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":0.2127659574468085,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0":0.2553191489361702,"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":0.0425531914893617}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0":0.48936170212765956,"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":0.2127659574468085,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0":0.2553191489361702,"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":0.0425531914893617}},"safari/26.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15":1}},"safari/18.5":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":1}},"safari/26.0.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15":1}},"firefox/135.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0":1},"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0":0.9473684210526315,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0":0.05263157894736842}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0":0.95,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0":0.05}},"chrome/148.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":0.7142857142857143,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":0.02040816326530612,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":0.2653061224489796}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":0.7142857142857143,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":0.02040816326530612,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":0.2653061224489796}},"chrome/142.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.944954128440367,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.01834862385321101,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.03669724770642202}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.944954128440367,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.01834862385321101,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.03669724770642202}},"safari/18.3":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15":1}},"chrome/144.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.46153846153846156,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.3269230769230769,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.19230769230769232,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.019230769230769232}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.46153846153846156,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.3269230769230769,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.19230769230769232,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.019230769230769232}},"safari/18.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15":1}},"safari/26.3.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15":1}},"edge/129.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0":1}},"safari/18.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15":1}},"safari/18.6":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15":1}},"chrome/145.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.09523809523809523,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.46320346320346323,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.44155844155844154}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.09523809523809523,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.46320346320346323,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.44155844155844154}},"chrome/127.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":0.3333333333333333,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":0.6666666666666666}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":0.3333333333333333,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":0.6666666666666666}},"safari/16.6":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15":1}},"chrome/141.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":0.75,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":0.2,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":0.05}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":0.75,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":0.2,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":0.05}},"chrome/121.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36":1}},"chrome/143.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.4716981132075472,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.4339622641509434,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.09433962264150944}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.4716981132075472,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.4339622641509434,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.09433962264150944}},"chrome/128.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":0.625,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":0.375}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":0.625,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":0.375}},"safari/17.2.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15":1}},"safari/26.5":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15":1}},"chrome/116.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":1}},"chrome/138.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":0.5957446808510638,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":0.3404255319148936,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":0.06382978723404255}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":0.5957446808510638,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":0.3404255319148936,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":0.06382978723404255}},"safari/26.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Safari/605.1.15":1}},"safari/17.6":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15":1}},"firefox/151.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0":1}},"chrome/130.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":0.25,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":0.5,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":0.25}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":0.25,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":0.5,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":0.25}},"chrome/115.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36":1}},"edge/146.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":1}},"chrome/149.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":0.18181818181818182,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":0.6363636363636364,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":0.18181818181818182}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":0.18181818181818182,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":0.6363636363636364,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":0.18181818181818182}},"chrome/140.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":0.5714285714285714,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":0.21428571428571427,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":0.21428571428571427}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":0.5714285714285714,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":0.21428571428571427,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":0.21428571428571427}},"safari/16.4":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15":1}},"safari/18.1.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15":1}},"edge/144.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0":1}},"edge/128.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0":1}},"chrome/109.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36":1}},"chrome/139.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":0.8461538461538461,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":0.15384615384615385}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":0.8461538461538461,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":0.15384615384615385}},"edge/143.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":0.8888888888888888,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":0.1111111111111111}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":0.8888888888888888,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":0.1111111111111111}},"safari/17.4":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15":1}},"chrome/131.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":0.36363636363636365,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":0.45454545454545453,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":0.18181818181818182}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":0.36363636363636365,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":0.45454545454545453,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":0.18181818181818182}},"safari/18.4":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15":1}},"safari/18.0.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15":1}},"chrome/134.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":0.75,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":0.25}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":0.75,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":0.25}},"safari/16.5.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Safari/605.1.15":1}},"chrome/120.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36":1}},"chrome/137.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":0.8,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":0.2}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":0.8,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":0.2}},"safari/26.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15":1}},"safari/16.6.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15":1}},"firefox/147.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0":0.5,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0":0.5}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0":0.5,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0":0.5}},"edge/145.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":0.8,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":0.2}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":0.8,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":0.2}},"chrome/135.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":0.8181818181818182,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":0.09090909090909091,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":0.09090909090909091}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":0.8181818181818182,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":0.09090909090909091,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":0.09090909090909091}},"safari/17.5":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15":1}},"chrome/91.0.4450.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4450.0 Safari/537.36 LarkUrl":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4450.0 Safari/537.36 LarkUrl":1}},"chrome/125.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36":1}},"chrome/126.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":0.5555555555555556,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":0.2222222222222222,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":0.2222222222222222}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":0.5555555555555556,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":0.2222222222222222,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":0.2222222222222222}},"chrome/113.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36":1}},"chrome/101.0.4951.54":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36":1}},"chrome/108.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36":1}},"edge/148.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0":1}},"chrome/147.0.7727.56":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.56 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.56 Safari/537.36":1}},"safari/17.4.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15":1}},"edge/123.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0":1}},"chrome/136.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":0.8333333333333334,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":0.16666666666666666}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":0.8333333333333334,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":0.16666666666666666}},"safari/18.3.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15":1}},"edge/138.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0":1}},"chrome/114.0.0.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36":1}},"safari/16.5":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15":1}},"chrome/138.0.7204.235":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.235 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.235 Safari/537.36":1}},"safari/17.3":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15":1}},"edge/142.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0":1}},"firefox/149.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0":0.42857142857142855,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0":0.2857142857142857,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0":0.2857142857142857}},"skip":{"Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0":0.42857142857142855,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0":0.2857142857142857,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0":0.2857142857142857}},"safari/15.6.8":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Safari/605.1.15":1}},"safari/17.3.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15":1}},"chrome/124.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36":1}},"chrome/122.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36":1}},"chrome/107.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36":1}},"firefox/146.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0":1}},"edge/122.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0":1}},"chrome/103.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36":1}},"chrome/90.0.4430.212":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36":1}},"safari/17.1.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15":1}},"chrome/132.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36":1}},"safari/18.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15":1}},"chrome/133.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36":1}},"firefox/142.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0":1}},"safari/17.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15":1}},"safari/17.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15":1}},"safari/15.6.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_8) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_8) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15":1}},"safari/17.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15":1}},"edge/135.0.3179.54":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.3179.54":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.3179.54":1}},"chrome/106.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Atom/26.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Atom/26.0.0.0 Safari/537.36":1}},"safari/16.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15":1}},"chrome/91.0.4472.124":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36":1}},"chrome/132.0.6788.76":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6788.76 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6788.76 Safari/537.36":1}},"safari/16.3":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15":1}}},"skip":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.1958399435924555,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.5903402080028204,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.08011634056054998,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.024678300722721664,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.015864621893178214,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.003789881896703684,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":0.006169575180680416,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.009078089194429755,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.0021152829190904283,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":0.00017627357659086903,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0":0.00026441036488630354,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.009695046712497797,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.0019390093424995593,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":0.00008813678829543451,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.009430636347611493,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.008989952406134321,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":0.0022915564956812974,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":0.0013220518244315177,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36":0.00035254715318173806,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.002203419707385863,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":0.00044068394147717257,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":0.00035254715318173806,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":0.0024678300722721664,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":0.00017627357659086903,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36":0.00008813678829543451,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":0.002203419707385863,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":0.00017627357659086903,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":0.0014101886127269522,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":0.0007050943063634761,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0":0.00017627357659086903,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0":0.00008813678829543451,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36":0.002203419707385863,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":0.0009695046712497797,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":0.0007050943063634761,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":0.00035254715318173806,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.0018508725542041248,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":0.0005288207297726071,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":0.00035254715318173806,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36":0.00026441036488630354,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":0.0007050943063634761,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":0.00026441036488630354,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":0.00035254715318173806,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":0.0007932310946589106,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.002027146130794994,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.0014983254010223867,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36":0.0008813678829543451,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.00044068394147717257,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":0.00044068394147717257,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36":0.00008813678829543451,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.00017627357659086903,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.0008813678829543451,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.0007050943063634761,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36":0.00017627357659086903,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0":0.0015864621893178213,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.56 Safari/537.36":0.00044068394147717257,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0":0.00026441036488630354,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":0.00044068394147717257,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":0.0006169575180680416,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0":0.00008813678829543451,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":0.00008813678829543451,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0":0.00008813678829543451,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":0.00035254715318173806,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0":0.00017627357659086903,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":0.00017627357659086903,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36":0.00026441036488630354,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36":0.00044068394147717257,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 CCleaner/146.0.34394.179":0.00008813678829543451,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36":0.00017627357659086903,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.00035254715318173806,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0":0.00008813678829543451,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36":0.00008813678829543451,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":0.00017627357659086903,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.00017627357659086903,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":0.00008813678829543451,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":0.00044068394147717257,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":0.00017627357659086903,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36":0.00026441036488630354,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":0.00017627357659086903,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36":0.00044068394147717257,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":0.00026441036488630354,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":0.00017627357659086903,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":0.00017627357659086903,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":0.00008813678829543451,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":0.00017627357659086903,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":0.00026441036488630354,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":0.00008813678829543451,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.3179.54":0.00008813678829543451,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":0.00017627357659086903,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.00008813678829543451,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":0.00008813678829543451,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.00008813678829543451,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":0.00017627357659086903,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Atom/26.0.0.0 Safari/537.36":0.00017627357659086903,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":0.00026441036488630354,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":0.00008813678829543451,"Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6788.76 Safari/537.36":0.00008813678829543451},"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":0.27060539752005836,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0":0.016776075857038657,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15":0.0787746170678337,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15":0.08169219547775347,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15":0.019693654266958426,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0":0.01312910284463895,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15":0.013858497447118891,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15":0.0036469730123997084,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15":0.187454412837345,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15":0.016046681254558718,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15":0.09700948212983224,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15":0.00437636761487965,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":0.02698760029175784,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15":0.0007293946024799417,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15":0.0175054704595186,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0":0.0014587892049598833,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15":0.019693654266958426,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15":0.0014587892049598833,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15":0.0036469730123997084,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Safari/605.1.15":0.016046681254558718,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15":0.002188183807439825,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15":0.0007293946024799417,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Safari/605.1.15":0.0007293946024799417,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15":0.01312910284463895,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15":0.0014587892049598833,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0":0.0014587892049598833,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15":0.0087527352297593,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4450.0 Safari/537.36 LarkUrl":0.002188183807439825,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36":0.007293946024799417,"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":0.007293946024799417,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15":0.0036469730123997084,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0":0.0007293946024799417,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15":0.002188183807439825,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.0036469730123997084,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36":0.0087527352297593,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15":0.0029175784099197666,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.235 Safari/537.36":0.0007293946024799417,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15":0.002188183807439825,"Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0":0.002188183807439825,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.0007293946024799417,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0":0.0087527352297593,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Safari/605.1.15":0.0007293946024799417,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15":0.0029175784099197666,"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0":0.0014587892049598833,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15":0.0029175784099197666,"Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36":0.0007293946024799417,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15":0.0007293946024799417,"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":0.0014587892049598833,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0":0.0014587892049598833,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15":0.0029175784099197666,"Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0":0.0014587892049598833,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15":0.0007293946024799417,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15":0.0014587892049598833,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0":0.0014587892049598833,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_8) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15":0.0007293946024799417,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15":0.0007293946024799417,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0":0.0014587892049598833,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15":0.0007293946024799417,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36":0.0029175784099197666,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15":0.0007293946024799417}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.1751199182197059,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.5266965479279704,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.07147912243453644,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":0.02917354722025635,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.022017771486985926,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0":0.0018086026578595581,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15":0.008492569002123142,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15":0.00880710859479437,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15":0.0021231422505307855,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.014154281670205236,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.0033813006212156955,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0":0.0014940630651883305,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":0.0055044428717464814,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.008099394511284108,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15":0.0014940630651883305,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.001887237556027365,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":0.00015726979633561374,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15":0.0003931744908390344,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15":0.020209168829126368,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0":0.00023590469450342062,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15":0.0017299677596917511,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.008728473696626562,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15":0.010458441456318314,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.0017299677596917511,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":0.00007863489816780687,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.008413934103955335,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.008020759613116301,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":0.0020445073523629787,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15":0.00047180938900684123,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":0.0029094912322088543,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":0.0011795234725171032,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36":0.0003145395926712275,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.001965872454195172,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":0.0003931744908390344,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15":0.00007863489816780687,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":0.0003145395926712275,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":0.0022017771486985923,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15":0.001887237556027365,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0":0.00015726979633561374,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":0.00015726979633561374,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36":0.00007863489816780687,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15":0.0021231422505307855,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":0.001965872454195172,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":0.00015726979633561374,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":0.00125815837068491,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":0.000629079185342455,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15":0.00015726979633561374,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15":0.0003931744908390344,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0":0.00015726979633561374,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Safari/605.1.15":0.0017299677596917511,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0":0.00007863489816780687,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36":0.001965872454195172,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":0.0008649838798458756,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":0.000629079185342455,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15":0.00023590469450342062,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":0.0003145395926712275,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15":0.00007863489816780687,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.0016513328615239443,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":0.00047180938900684123,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Safari/605.1.15":0.00007863489816780687,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":0.0003145395926712275,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36":0.00023590469450342062,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":0.000629079185342455,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15":0.0014154281670205238,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15":0.00015726979633561374,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":0.00023590469450342062,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0":0.00015726979633561374,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":0.0003145395926712275,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":0.0007077140835102619,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.0018086026578595581,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15":0.0009436187780136825,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.0013367932688527167,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4450.0 Safari/537.36 LarkUrl":0.00023590469450342062,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36":0.0007863489816780688,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.0003931744908390344,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":0.0003931744908390344,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36":0.00007863489816780687,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.00015726979633561374,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36":0.0007863489816780688,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.0007863489816780688,"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":0.0007863489816780688,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.000629079185342455,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36":0.00015726979633561374,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0":0.0014154281670205238,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.56 Safari/537.36":0.0003931744908390344,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15":0.0003931744908390344,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0":0.00007863489816780687,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0":0.00023590469450342062,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":0.0003931744908390344,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":0.0005504442871746481,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0":0.00007863489816780687,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15":0.00023590469450342062,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36":0.0009436187780136825,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":0.00007863489816780687,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15":0.0003145395926712275,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.235 Safari/537.36":0.00007863489816780687,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":0.0003145395926712275,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15":0.00023590469450342062,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0":0.00015726979633561374,"Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0":0.00023590469450342062,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0":0.0009436187780136825,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":0.00015726979633561374,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Safari/605.1.15":0.00007863489816780687,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15":0.0003145395926712275,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36":0.00023590469450342062,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36":0.0003931744908390344,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 CCleaner/146.0.34394.179":0.00007863489816780687,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36":0.00015726979633561374,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.0003145395926712275,"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0":0.00015726979633561374,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0":0.00007863489816780687,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15":0.0003145395926712275,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36":0.00007863489816780687,"Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36":0.00007863489816780687,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15":0.00007863489816780687,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":0.00015726979633561374,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.00015726979633561374,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":0.00007863489816780687,"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":0.00015726979633561374,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":0.0003931744908390344,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0":0.00015726979633561374,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":0.00015726979633561374,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36":0.00023590469450342062,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15":0.0003145395926712275,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":0.00015726979633561374,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36":0.0003931744908390344,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":0.00023590469450342062,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":0.00015726979633561374,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":0.00015726979633561374,"Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0":0.00015726979633561374,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15":0.00007863489816780687,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15":0.00015726979633561374,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":0.00007863489816780687,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":0.00015726979633561374,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0":0.00015726979633561374,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":0.00023590469450342062,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":0.00007863489816780687,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_8) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15":0.00007863489816780687,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15":0.00007863489816780687,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.3179.54":0.00007863489816780687,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":0.00015726979633561374,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":0.00007863489816780687,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":0.00007863489816780687,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.00007863489816780687,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":0.00015726979633561374,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Atom/26.0.0.0 Safari/537.36":0.00015726979633561374,"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0":0.00015726979633561374,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15":0.00007863489816780687,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":0.00023590469450342062,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":0.00007863489816780687,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36":0.0003145395926712275,"Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6788.76 Safari/537.36":0.00007863489816780687,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15":0.00007863489816780687}}}},"mobile":{"deeper":{"ios":{"deeper":{"safari/26.4":{"deeper":{"?0":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":1},"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":0.992831541218638,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":0.007168458781362007}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":0.9928571428571429,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":0.007142857142857143}},"safari/26.3":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/15E148 Safari/604.1":0.9953703703703703,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/23D127 Safari/604.1":0.0023148148148148147,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1":0.0023148148148148147}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/15E148 Safari/604.1":0.9953703703703703,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/23D127 Safari/604.1":0.0023148148148148147,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1":0.0023148148148148147}},"safari/26.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1":0.971830985915493,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1 Brave":0.028169014084507043}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1":0.971830985915493,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1 Brave":0.028169014084507043}},"safari/18.5":{"deeper":{"?0":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1":1},"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1":1}},"safari/26.0.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Mobile/15E148 Safari/604.1":1}},"safari/18.3":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1":1}},"safari/18.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":0.75,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":0.25}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":0.75,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":0.25}},"safari/26.3.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1 Brave":0.2857142857142857,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/23D8133 Safari/604.1":0.5714285714285714,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1":0.14285714285714285}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1 Brave":0.2857142857142857,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/23D8133 Safari/604.1":0.5714285714285714,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1":0.14285714285714285}},"safari/18.7":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":0.75,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":0.25}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":0.75,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":0.25}},"safari/18.7.4":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.4 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.4 Mobile/15E148 Safari/604.1":1}},"safari/18.6":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.8846153846153846,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.07692307692307693,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.038461538461538464}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.8846153846153846,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.07692307692307693,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.038461538461538464}},"safari/18.7.5":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.5 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.5 Mobile/15E148 Safari/604.1":1}},"safari/26.5":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":0.9428571428571428,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":0.05714285714285714}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":0.9428571428571428,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":0.05714285714285714}},"safari/26.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1":0.8888888888888888,"Mozilla/5.0 (iPad; CPU OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1 Brave":0.1111111111111111}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1":0.8888888888888888,"Mozilla/5.0 (iPad; CPU OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1 Brave":0.1111111111111111}},"safari/17.6":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1":1}},"safari/18.4":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":0.6666666666666666,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":0.3333333333333333}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":0.6666666666666666,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":0.3333333333333333}},"safari/26.4.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 Brave":0.375,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 GrokApp/1.3.71":0.125,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/23E261 Safari/604.1":0.375,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1":0.125}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 Brave":0.375,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 GrokApp/1.3.71":0.125,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/23E261 Safari/604.1":0.375,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1":0.125}},"safari/26.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1":1}},"safari/16.6.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_14 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_14 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Mobile/15E148 Safari/604.1":1}},"safari/17.5":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1":1}},"chrome/45.0.8909.1591":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.8909.1591 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.8909.1591 Mobile Safari/537.36":1}},"safari/17.7":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1":1}},"safari/17.4.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1":1}},"safari/16.6.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_15 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.2 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_15 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.2 Mobile/15E148 Safari/604.1":1}},"safari/18.3.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Mobile/15E148 Safari/604.1":1}},"safari/16.5":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1":1}},"chrome/144.0.7559.95":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/144.0.7559.95 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/144.0.7559.95 Mobile/15E148 Safari/604.1":1}},"safari/18.7.3":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.3 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.3 Mobile/15E148 Safari/604.1":1}},"safari/17.8":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1":1}},"safari/15.6.8":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Mobile/15E148 Safari/604.1":1}},"safari/15.6.7":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.7 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.7 Mobile/15E148 Safari/604.1":1}},"safari/16.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1":1}},"safari/18.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1":1}},"safari/18.7.7":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.7 Mobile/22H340 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.7 Mobile/22H340 Safari/604.1":1}},"safari/18.7.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.2 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.2 Mobile/15E148 Safari/604.1":1}},"safari/18.4.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4.1 Mobile/22E252 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4.1 Mobile/22E252 Safari/604.1":1}},"safari/26.4.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.1 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.1 Mobile/15E148 Safari/604.1":1}}},"skip":{"deeper":{"?0":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1":0.5,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":0.5},"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/15E148 Safari/604.1":0.41707080504364696,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":0.26867119301648884,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":0.002909796314258002,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.4 Mobile/15E148 Safari/604.1":0.0038797284190106693,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1":0.06692531522793405,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.5 Mobile/15E148 Safari/604.1":0.04267701260911736,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1":0.005819592628516004,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1 Brave":0.0019398642095053346,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":0.03200775945683802,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1":0.023278370514064017,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.02230843840931135,"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1":0.009699321047526674,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1":0.011639185257032008,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":0.002909796314258002,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":0.0019398642095053346,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 Brave":0.002909796314258002,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.0019398642095053346,"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.8909.1591 Mobile Safari/537.36":0.0009699321047526673,"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1":0.0019398642095053346,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.0009699321047526673,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 GrokApp/1.3.71":0.0009699321047526673,"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_15 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.2 Mobile/15E148 Safari/604.1":0.004849660523763337,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Mobile/15E148 Safari/604.1":0.0038797284190106693,"Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1":0.0019398642095053346,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/144.0.7559.95 Mobile/15E148 Safari/604.1":0.0019398642095053346,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/23D127 Safari/604.1":0.0009699321047526673,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.3 Mobile/15E148 Safari/604.1":0.002909796314258002,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1":0.004849660523763337,"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1":0.0009699321047526673,"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.7 Mobile/15E148 Safari/604.1":0.004849660523763337,"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_14 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Mobile/15E148 Safari/604.1":0.0009699321047526673,"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1":0.0009699321047526673,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":0.0009699321047526673,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":0.0019398642095053346,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":0.0019398642095053346,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1 Brave":0.0019398642095053346,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/23D8133 Safari/604.1":0.0038797284190106693,"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1":0.0009699321047526673,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Mobile/15E148 Safari/604.1":0.007759456838021339,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/23E261 Safari/604.1":0.002909796314258002,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1":0.0009699321047526673,"Mozilla/5.0 (iPad; CPU OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1 Brave":0.002909796314258002,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.7 Mobile/22H340 Safari/604.1":0.0009699321047526673,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":0.0009699321047526673,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.2 Mobile/15E148 Safari/604.1":0.0019398642095053346,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4.1 Mobile/22E252 Safari/604.1":0.0009699321047526673,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1":0.0009699321047526673,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1":0.0009699321047526673,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":0.0009699321047526673,"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Mobile/15E148 Safari/604.1":0.01066925315227934,"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1":0.0009699321047526673,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.1 Mobile/15E148 Safari/604.1":0.0009699321047526673,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1":0.0009699321047526673}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/15E148 Safari/604.1":0.41626331074540174,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":0.2691190706679574,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1":0.012584704743465635,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":0.002904162633107454,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.4 Mobile/15E148 Safari/604.1":0.003872216844143272,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1":0.06679574056147145,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.5 Mobile/15E148 Safari/604.1":0.04259438528557599,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1":0.005808325266214908,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1 Brave":0.001936108422071636,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":0.031945788964181994,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1":0.023233301064859633,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.022265246853823813,"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1":0.00968054211035818,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":0.002904162633107454,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":0.001936108422071636,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 Brave":0.002904162633107454,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.001936108422071636,"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.8909.1591 Mobile Safari/537.36":0.000968054211035818,"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1":0.001936108422071636,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.000968054211035818,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 GrokApp/1.3.71":0.000968054211035818,"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_15 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.2 Mobile/15E148 Safari/604.1":0.00484027105517909,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Mobile/15E148 Safari/604.1":0.003872216844143272,"Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1":0.001936108422071636,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/144.0.7559.95 Mobile/15E148 Safari/604.1":0.001936108422071636,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/23D127 Safari/604.1":0.000968054211035818,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.3 Mobile/15E148 Safari/604.1":0.002904162633107454,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1":0.00484027105517909,"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1":0.000968054211035818,"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.7 Mobile/15E148 Safari/604.1":0.00484027105517909,"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_14 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Mobile/15E148 Safari/604.1":0.000968054211035818,"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1":0.000968054211035818,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":0.000968054211035818,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":0.001936108422071636,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":0.001936108422071636,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1 Brave":0.001936108422071636,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/23D8133 Safari/604.1":0.003872216844143272,"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1":0.000968054211035818,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Mobile/15E148 Safari/604.1":0.007744433688286544,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/23E261 Safari/604.1":0.002904162633107454,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1":0.000968054211035818,"Mozilla/5.0 (iPad; CPU OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1 Brave":0.002904162633107454,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.7 Mobile/22H340 Safari/604.1":0.000968054211035818,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":0.000968054211035818,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.2 Mobile/15E148 Safari/604.1":0.001936108422071636,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4.1 Mobile/22E252 Safari/604.1":0.000968054211035818,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1":0.000968054211035818,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1":0.000968054211035818,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":0.000968054211035818,"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Mobile/15E148 Safari/604.1":0.010648596321393998,"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1":0.000968054211035818,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.1 Mobile/15E148 Safari/604.1":0.000968054211035818,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1":0.000968054211035818}}},"android":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1},"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":0.996,"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":0.004}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":0.9803149606299213,"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":0.003937007874015748,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.015748031496062992}},"edge/147.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 Edg/147.0.0.0":0.16666666666666666,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 EdgA/147.0.0.0":0.8333333333333334}},"skip":{"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 Edg/147.0.0.0":0.16666666666666666,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 EdgA/147.0.0.0":0.8333333333333334}},"chrome/146.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":1},"?1":{"Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36":0.3333333333333333,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.6666666666666666}},"firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Android 16; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0":1}},"skip":{"Mozilla/5.0 (Android 16; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0":1}},"chrome/148.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36":1}},"chrome/144.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36":1}},"chrome/145.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36":1}},"chrome/143.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":0.25,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":0.75}},"skip":{"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":0.25,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":0.75}},"chrome/138.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36":1}},"edge/143.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36 EdgA/143.0.0.0":1}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36 EdgA/143.0.0.0":1}},"chrome/134.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Mobile Safari/537.36":1}},"chrome/120.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36":1}},"chrome/137.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36":1}},"chrome/119.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36":1}},"chrome/142.0.7444.138":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.138 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.138 Mobile Safari/537.36":1}},"firefox/136.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Android 13; Mobile; rv:136.0) Gecko/136.0 Firefox/136.0":1}},"skip":{"Mozilla/5.0 (Android 13; Mobile; rv:136.0) Gecko/136.0 Firefox/136.0":1}},"chrome/59.0.9273.1293":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.9273.1293 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.9273.1293 Mobile Safari/537.36":1}},"chrome/138.0.7204.63":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Linux; Android 12; X16DzOXpOQ; U; en) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.63 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 12; X16DzOXpOQ; U; en) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.63 Mobile Safari/537.36":1}},"chrome/147.0.7727.111":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Linux; Android 16; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":1},"?1":{"Mozilla/5.0 (Linux; Android 15; SM-G991W Build/AP3A.240905.015.A2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 15; SM-G991W Build/AP3A.240905.015.A2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":0.16666666666666666,"Mozilla/5.0 (Linux; Android 16; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":0.8333333333333334}},"chrome/130.0.6723.73":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 15; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.73 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 15; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.73 Mobile Safari/537.36":1}}},"skip":{"deeper":{"?0":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.6666666666666666,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.3333333333333333},"*MISSING_VALUE*":{"Mozilla/5.0 (Android 16; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0":0.5789473684210527,"Mozilla/5.0 (Android 13; Mobile; rv:136.0) Gecko/136.0 Firefox/136.0":0.05263157894736842,"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.9273.1293 Mobile Safari/537.36":0.05263157894736842,"Mozilla/5.0 (Linux; Android 12; X16DzOXpOQ; U; en) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.63 Mobile Safari/537.36":0.05263157894736842,"Mozilla/5.0 (Linux; Android 16; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":0.2631578947368421},"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":0.8137254901960784,"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":0.0032679738562091504,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36":0.0457516339869281,"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 Edg/147.0.0.0":0.0032679738562091504,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 EdgA/147.0.0.0":0.016339869281045753,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36 EdgA/143.0.0.0":0.006535947712418301,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36":0.0196078431372549,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.138 Mobile Safari/537.36":0.0032679738562091504,"Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36":0.0032679738562091504,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36":0.013071895424836602,"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":0.0032679738562091504,"Mozilla/5.0 (Linux; Android 15; SM-G991W Build/AP3A.240905.015.A2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":0.0032679738562091504,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Mobile Safari/537.36":0.006535947712418301,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36":0.026143790849673203,"Mozilla/5.0 (Linux; Android 15; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.73 Mobile Safari/537.36":0.0032679738562091504,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":0.00980392156862745,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36":0.006535947712418301,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36":0.00980392156862745,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36":0.0032679738562091504}}}}},"skip":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1},"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":0.996,"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":0.004}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":0.9803149606299213,"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":0.003937007874015748,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.015748031496062992}},"edge/147.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 Edg/147.0.0.0":0.16666666666666666,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 EdgA/147.0.0.0":0.8333333333333334}},"skip":{"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 Edg/147.0.0.0":0.16666666666666666,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 EdgA/147.0.0.0":0.8333333333333334}},"safari/26.4":{"deeper":{"?0":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":1},"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":0.992831541218638,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":0.007168458781362007}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":0.9928571428571429,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":0.007142857142857143}},"safari/26.3":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/15E148 Safari/604.1":0.9953703703703703,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/23D127 Safari/604.1":0.0023148148148148147,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1":0.0023148148148148147}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/15E148 Safari/604.1":0.9953703703703703,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/23D127 Safari/604.1":0.0023148148148148147,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1":0.0023148148148148147}},"chrome/146.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":1},"?1":{"Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36":0.3333333333333333,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.6666666666666666}},"firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Android 16; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0":1}},"skip":{"Mozilla/5.0 (Android 16; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0":1}},"safari/26.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1":0.971830985915493,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1 Brave":0.028169014084507043}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1":0.971830985915493,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1 Brave":0.028169014084507043}},"safari/18.5":{"deeper":{"?0":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1":1},"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1":1}},"safari/26.0.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Mobile/15E148 Safari/604.1":1}},"chrome/148.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36":1}},"safari/18.3":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1":1}},"chrome/144.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36":1}},"safari/18.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":0.75,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":0.25}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":0.75,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":0.25}},"safari/26.3.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1 Brave":0.2857142857142857,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/23D8133 Safari/604.1":0.5714285714285714,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1":0.14285714285714285}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1 Brave":0.2857142857142857,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/23D8133 Safari/604.1":0.5714285714285714,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1":0.14285714285714285}},"safari/18.7":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":0.75,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":0.25}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":0.75,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":0.25}},"safari/18.7.4":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.4 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.4 Mobile/15E148 Safari/604.1":1}},"safari/18.6":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.8846153846153846,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.07692307692307693,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.038461538461538464}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.8846153846153846,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.07692307692307693,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.038461538461538464}},"safari/18.7.5":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.5 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.5 Mobile/15E148 Safari/604.1":1}},"chrome/145.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36":1}},"chrome/143.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":0.25,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":0.75}},"skip":{"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":0.25,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":0.75}},"safari/26.5":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":0.9428571428571428,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":0.05714285714285714}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":0.9428571428571428,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":0.05714285714285714}},"chrome/138.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36":1}},"safari/26.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1":0.8888888888888888,"Mozilla/5.0 (iPad; CPU OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1 Brave":0.1111111111111111}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1":0.8888888888888888,"Mozilla/5.0 (iPad; CPU OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1 Brave":0.1111111111111111}},"safari/17.6":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1":1}},"edge/143.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36 EdgA/143.0.0.0":1}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36 EdgA/143.0.0.0":1}},"safari/18.4":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":0.6666666666666666,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":0.3333333333333333}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":0.6666666666666666,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":0.3333333333333333}},"chrome/134.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Mobile Safari/537.36":1}},"safari/26.4.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 Brave":0.375,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 GrokApp/1.3.71":0.125,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/23E261 Safari/604.1":0.375,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1":0.125}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 Brave":0.375,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 GrokApp/1.3.71":0.125,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/23E261 Safari/604.1":0.375,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1":0.125}},"chrome/120.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36":1}},"chrome/137.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36":1}},"safari/26.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1":1}},"safari/16.6.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_14 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_14 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Mobile/15E148 Safari/604.1":1}},"safari/17.5":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1":1}},"chrome/45.0.8909.1591":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.8909.1591 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.8909.1591 Mobile Safari/537.36":1}},"safari/17.7":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1":1}},"safari/17.4.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1":1}},"safari/16.6.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_15 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.2 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_15 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.2 Mobile/15E148 Safari/604.1":1}},"chrome/119.0.0.0":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36":1}},"chrome/142.0.7444.138":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.138 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.138 Mobile Safari/537.36":1}},"safari/18.3.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Mobile/15E148 Safari/604.1":1}},"firefox/136.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Android 13; Mobile; rv:136.0) Gecko/136.0 Firefox/136.0":1}},"skip":{"Mozilla/5.0 (Android 13; Mobile; rv:136.0) Gecko/136.0 Firefox/136.0":1}},"safari/16.5":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1":1}},"chrome/59.0.9273.1293":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.9273.1293 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.9273.1293 Mobile Safari/537.36":1}},"chrome/144.0.7559.95":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/144.0.7559.95 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/144.0.7559.95 Mobile/15E148 Safari/604.1":1}},"safari/18.7.3":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.3 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.3 Mobile/15E148 Safari/604.1":1}},"chrome/138.0.7204.63":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Linux; Android 12; X16DzOXpOQ; U; en) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.63 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 12; X16DzOXpOQ; U; en) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.63 Mobile Safari/537.36":1}},"safari/17.8":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1":1}},"safari/15.6.8":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Mobile/15E148 Safari/604.1":1}},"chrome/147.0.7727.111":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Linux; Android 16; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":1},"?1":{"Mozilla/5.0 (Linux; Android 15; SM-G991W Build/AP3A.240905.015.A2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 15; SM-G991W Build/AP3A.240905.015.A2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":0.16666666666666666,"Mozilla/5.0 (Linux; Android 16; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":0.8333333333333334}},"safari/15.6.7":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.7 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.7 Mobile/15E148 Safari/604.1":1}},"safari/16.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1":1}},"safari/18.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1":1}},"chrome/130.0.6723.73":{"deeper":{"?1":{"Mozilla/5.0 (Linux; Android 15; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.73 Mobile Safari/537.36":1}},"skip":{"Mozilla/5.0 (Linux; Android 15; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.73 Mobile Safari/537.36":1}},"safari/18.7.7":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.7 Mobile/22H340 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.7 Mobile/22H340 Safari/604.1":1}},"safari/18.7.2":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.2 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.2 Mobile/15E148 Safari/604.1":1}},"safari/18.4.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4.1 Mobile/22E252 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4.1 Mobile/22E252 Safari/604.1":1}},"safari/26.4.1":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.1 Mobile/15E148 Safari/604.1":1}},"skip":{"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.1 Mobile/15E148 Safari/604.1":1}}},"skip":{"deeper":{"?0":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1":0.125,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.5,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":0.125,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.25},"*MISSING_VALUE*":{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/15E148 Safari/604.1":0.4095238095238095,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":0.2638095238095238,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":0.002857142857142857,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.4 Mobile/15E148 Safari/604.1":0.0038095238095238095,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1":0.06571428571428571,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.5 Mobile/15E148 Safari/604.1":0.0419047619047619,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1":0.005714285714285714,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1 Brave":0.0019047619047619048,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":0.03142857142857143,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1":0.022857142857142857,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.021904761904761906,"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1":0.009523809523809525,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1":0.011428571428571429,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":0.002857142857142857,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":0.0019047619047619048,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 Brave":0.002857142857142857,"Mozilla/5.0 (Android 16; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0":0.010476190476190476,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.0019047619047619048,"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.8909.1591 Mobile Safari/537.36":0.0009523809523809524,"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1":0.0019047619047619048,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":0.0009523809523809524,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 GrokApp/1.3.71":0.0009523809523809524,"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_15 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.2 Mobile/15E148 Safari/604.1":0.004761904761904762,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Mobile/15E148 Safari/604.1":0.0038095238095238095,"Mozilla/5.0 (Android 13; Mobile; rv:136.0) Gecko/136.0 Firefox/136.0":0.0009523809523809524,"Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1":0.0019047619047619048,"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.9273.1293 Mobile Safari/537.36":0.0009523809523809524,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/144.0.7559.95 Mobile/15E148 Safari/604.1":0.0019047619047619048,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/23D127 Safari/604.1":0.0009523809523809524,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.3 Mobile/15E148 Safari/604.1":0.002857142857142857,"Mozilla/5.0 (Linux; Android 12; X16DzOXpOQ; U; en) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.63 Mobile Safari/537.36":0.0009523809523809524,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1":0.004761904761904762,"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1":0.0009523809523809524,"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.7 Mobile/15E148 Safari/604.1":0.004761904761904762,"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_14 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Mobile/15E148 Safari/604.1":0.0009523809523809524,"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1":0.0009523809523809524,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":0.0009523809523809524,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":0.0019047619047619048,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":0.0019047619047619048,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1 Brave":0.0019047619047619048,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/23D8133 Safari/604.1":0.0038095238095238095,"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1":0.0009523809523809524,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Mobile/15E148 Safari/604.1":0.007619047619047619,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/23E261 Safari/604.1":0.002857142857142857,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1":0.0009523809523809524,"Mozilla/5.0 (iPad; CPU OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1 Brave":0.002857142857142857,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.7 Mobile/22H340 Safari/604.1":0.0009523809523809524,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":0.0009523809523809524,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.2 Mobile/15E148 Safari/604.1":0.0019047619047619048,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4.1 Mobile/22E252 Safari/604.1":0.0009523809523809524,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1":0.0009523809523809524,"Mozilla/5.0 (Linux; Android 16; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":0.004761904761904762,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1":0.0009523809523809524,"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":0.0009523809523809524,"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Mobile/15E148 Safari/604.1":0.010476190476190476,"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1":0.0009523809523809524,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.1 Mobile/15E148 Safari/604.1":0.0009523809523809524,"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1":0.0009523809523809524},"?1":{"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":0.8137254901960784,"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":0.0032679738562091504,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36":0.0457516339869281,"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 Edg/147.0.0.0":0.0032679738562091504,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 EdgA/147.0.0.0":0.016339869281045753,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36 EdgA/143.0.0.0":0.006535947712418301,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36":0.0196078431372549,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.138 Mobile Safari/537.36":0.0032679738562091504,"Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36":0.0032679738562091504,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36":0.013071895424836602,"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":0.0032679738562091504,"Mozilla/5.0 (Linux; Android 15; SM-G991W Build/AP3A.240905.015.A2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":0.0032679738562091504,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Mobile Safari/537.36":0.006535947712418301,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36":0.026143790849673203,"Mozilla/5.0 (Linux; Android 15; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.73 Mobile Safari/537.36":0.0032679738562091504,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":0.00980392156862745,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36":0.006535947712418301,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36":0.00980392156862745,"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36":0.0032679738562091504}}}}}}},"_1.1_":{"deeper":{"desktop":{"deeper":{"macos":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.4":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.5":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"windows":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/146.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/142.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/145.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/143.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/116.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/146.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/96.0.4664.110":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":1}}},"skip":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":0.6666666666666666,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":0.3333333333333333}},"skip":{"*MISSING_VALUE*":0.989247311827957,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":0.010752688172043012}}},"linux":{"deeper":{"chrome/147.0.7727.116":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"*MISSING_VALUE*":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}}},"skip":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.4":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/146.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.5":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/142.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/145.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/143.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/116.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/146.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/96.0.4664.110":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":1}},"chrome/147.0.7727.116":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":0.9,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":0.1}},"skip":{"*MISSING_VALUE*":0.990990990990991,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":0.009009009009009009}}}}},"skip":{"deeper":{"macos":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.4":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.5":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"windows":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/146.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/142.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/145.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/143.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/116.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/146.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/96.0.4664.110":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":1}}},"skip":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":0.6666666666666666,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":0.3333333333333333}},"skip":{"*MISSING_VALUE*":0.989247311827957,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":0.010752688172043012}}},"linux":{"deeper":{"chrome/147.0.7727.116":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"*MISSING_VALUE*":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}}},"skip":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.4":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/146.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.5":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/142.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/145.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/143.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/116.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/146.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/96.0.4664.110":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":1}},"chrome/147.0.7727.116":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":0.9,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":0.1}},"skip":{"*MISSING_VALUE*":0.990990990990991,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":0.009009009009009009}}}}}}}},{"name":"User-Agent","parentNames":["*HTTP_VERSION","*DEVICE","*OPERATING_SYSTEM","*BROWSER","sec-ch-ua-mobile"],"possibleValues":["*MISSING_VALUE*","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36","Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"],"conditionalProbabilities":{"deeper":{"_2.0_":{"deeper":{"desktop":{"deeper":{"macos":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.4":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.3":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/146.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.5":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.0.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/135.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/148.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/142.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.3":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/144.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.3.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.6":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/145.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/127.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.6":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/141.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/143.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/128.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.2.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.5":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/116.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/138.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.6":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/151.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/130.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/115.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/149.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/140.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.4":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.1.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/139.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.4":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/131.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.4":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.0.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.5.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.6.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/147.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/135.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.5":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/91.0.4450.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.4.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.3.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/114.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.5":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/138.0.7204.235":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.3":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/149.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/15.6.8":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.3.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/107.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/103.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.1.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/15.6.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.3":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"windows":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/146.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/135.0":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/148.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/142.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/144.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/129.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/145.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/127.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/141.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/121.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/143.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/128.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/138.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/130.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/146.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/149.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/140.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/144.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/128.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/109.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/139.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/143.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/131.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/134.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/120.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/137.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/147.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/145.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/135.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/125.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/126.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/113.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/108.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/148.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/147.0.7727.56":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/123.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/136.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/138.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/142.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/149.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/124.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/122.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/122.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/132.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/133.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/135.0.3179.54":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/106.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/91.0.4472.124":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/132.0.6788.76":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"linux":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/146.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/148.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/142.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/144.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/145.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/141.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/143.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/138.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/130.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/149.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/140.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/143.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/131.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/134.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/137.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/145.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/135.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/126.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/101.0.4951.54":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/136.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/149.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/146.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/90.0.4430.212":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/142.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"*MISSING_VALUE*":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/146.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/144.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/126.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}}},"skip":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.4":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.3":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/146.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.5":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.0.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/135.0":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/148.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/142.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.3":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/144.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.3.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/129.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.6":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/145.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/127.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.6":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/141.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/121.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/143.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/128.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.2.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.5":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/116.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/138.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.6":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/151.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/130.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/115.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/146.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/149.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/140.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.4":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.1.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/144.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/128.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/109.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/139.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/143.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.4":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/131.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.4":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.0.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/134.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.5.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/120.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/137.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.6.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/147.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/145.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/135.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.5":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/91.0.4450.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/125.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/126.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/113.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/101.0.4951.54":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/108.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/148.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/147.0.7727.56":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.4.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/123.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/136.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.3.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/138.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/114.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.5":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/138.0.7204.235":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.3":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/142.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/149.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/15.6.8":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.3.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/124.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/122.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/107.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/146.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/122.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/103.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/90.0.4430.212":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.1.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/132.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/133.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/142.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/15.6.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/135.0.3179.54":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/106.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/91.0.4472.124":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/132.0.6788.76":{"deeper":{"?0":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.3":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}}},"mobile":{"deeper":{"ios":{"deeper":{"safari/26.4":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.3":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.5":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.0.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.3":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.3.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.7":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.7.4":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.6":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.7.5":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.5":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.6":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.4":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.4.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.6.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.5":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/45.0.8909.1591":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.7":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.4.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.6.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.3.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.5":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/144.0.7559.95":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.7.3":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.8":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/15.6.8":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/15.6.7":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.7.7":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.7.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.4.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.4.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"android":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1},"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/147.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/146.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1},"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/148.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/144.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/145.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/143.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/138.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/143.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/134.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/120.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/137.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/119.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/142.0.7444.138":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/136.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/59.0.9273.1293":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/138.0.7204.63":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/147.0.7727.111":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1},"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/130.0.6723.73":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1},"?1":{"*MISSING_VALUE*":1}}}}},"skip":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1},"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/147.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.4":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.3":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/146.0.0.0":{"deeper":{"?0":{"*MISSING_VALUE*":1},"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.5":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.0.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/148.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.3":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/144.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.3.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.7":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.7.4":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.6":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.7.5":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/145.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/143.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.5":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/138.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.6":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"edge/143.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.4":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/134.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.4.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/120.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/137.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.6.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.5":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/45.0.8909.1591":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.7":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.4.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.6.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/119.0.0.0":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/142.0.7444.138":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.3.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"firefox/136.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.5":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/59.0.9273.1293":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/144.0.7559.95":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.7.3":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/138.0.7204.63":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/17.8":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/15.6.8":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/147.0.7727.111":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1},"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/15.6.7":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/16.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/130.0.6723.73":{"deeper":{"?1":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.7.7":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.7.2":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/18.4.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"safari/26.4.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"?0":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1},"?1":{"*MISSING_VALUE*":1}}}}}}},"_1.1_":{"deeper":{"desktop":{"deeper":{"macos":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"safari/26.4":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":1}},"safari/18.5":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":1}}},"skip":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1},"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":0.3333333333333333,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":0.6666666666666666}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":0.07692307692307693,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.7692307692307693,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":0.15384615384615385}}},"windows":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"edge/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":1},"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":1}},"chrome/146.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":1}},"chrome/142.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":1}},"chrome/145.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":1}},"chrome/143.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":1}},"chrome/116.0.0.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":1}},"edge/146.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":1}},"chrome/96.0.4664.110":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.6444444444444445,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.18888888888888888,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":0.044444444444444446,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.011111111111111112,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.03333333333333333,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.011111111111111112,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.06666666666666667},"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.3333333333333333,"*MISSING_VALUE*":0.3333333333333333,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":0.3333333333333333}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.6236559139784946,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.1935483870967742,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":0.043010752688172046,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.010752688172043012,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.03225806451612903,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.010752688172043012,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.06451612903225806,"*MISSING_VALUE*":0.010752688172043012,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":0.010752688172043012}}},"linux":{"deeper":{"chrome/147.0.7727.116":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":1}}},"*MISSING_VALUE*":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}}},"skip":{"deeper":{"?0":{"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}}}},"skip":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.8405797101449275,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.014492753623188406,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.14492753623188406}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.8405797101449275,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.014492753623188406,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.14492753623188406}},"edge/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":1},"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":1}},"safari/26.4":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":1}},"chrome/146.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":1}},"safari/18.5":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":1}},"chrome/142.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":1}},"chrome/145.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":1}},"chrome/143.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":1}},"chrome/116.0.0.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":1}},"edge/146.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":1}},"chrome/96.0.4664.110":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/147.0.7727.116":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":1}}},"skip":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.5742574257425742,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.009900990099009901,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.16831683168316833,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":0.039603960396039604,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.009900990099009901,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.09900990099009901,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.0297029702970297,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.009900990099009901,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.0594059405940594},"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":0.1,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.1,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":0.2,"*MISSING_VALUE*":0.1,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":0.4,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":0.1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.5225225225225225,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.009009009009009009,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.16216216216216217,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":0.036036036036036036,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":0.009009009009009009,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.009009009009009009,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.09009009009009009,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":0.018018018018018018,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.02702702702702703,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.009009009009009009,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.05405405405405406,"*MISSING_VALUE*":0.009009009009009009,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":0.036036036036036036,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":0.009009009009009009}}}}},"skip":{"deeper":{"macos":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"safari/26.4":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":1}},"safari/18.5":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":1}}},"skip":{"deeper":{"?0":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1},"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":0.3333333333333333,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":0.6666666666666666}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":0.07692307692307693,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.7692307692307693,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":0.15384615384615385}}},"windows":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"edge/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":1},"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":1}},"chrome/146.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":1}},"chrome/142.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":1}},"chrome/145.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":1}},"chrome/143.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":1}},"chrome/116.0.0.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":1}},"edge/146.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":1}},"chrome/96.0.4664.110":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.6444444444444445,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.18888888888888888,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":0.044444444444444446,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.011111111111111112,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.03333333333333333,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.011111111111111112,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.06666666666666667},"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.3333333333333333,"*MISSING_VALUE*":0.3333333333333333,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":0.3333333333333333}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.6236559139784946,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.1935483870967742,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":0.043010752688172046,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.010752688172043012,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.03225806451612903,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.010752688172043012,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.06451612903225806,"*MISSING_VALUE*":0.010752688172043012,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":0.010752688172043012}}},"linux":{"deeper":{"chrome/147.0.7727.116":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":1}}},"*MISSING_VALUE*":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}}},"skip":{"deeper":{"?0":{"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":1}}}},"skip":{"deeper":{"chrome/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.8405797101449275,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.014492753623188406,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.14492753623188406}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.8405797101449275,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.014492753623188406,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.14492753623188406}},"edge/147.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":1},"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":1}},"safari/26.4":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":1}},"chrome/146.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":1}},"safari/18.5":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":1}},"skip":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":1}},"chrome/142.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":1}},"chrome/145.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":1}},"chrome/143.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":1}},"chrome/116.0.0.0":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":1}},"edge/146.0.0.0":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":1}},"chrome/96.0.4664.110":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"chrome/147.0.7727.116":{"deeper":{"*MISSING_VALUE*":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":1}},"skip":{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":1}}},"skip":{"deeper":{"?0":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.5742574257425742,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.009900990099009901,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.16831683168316833,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":0.039603960396039604,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.009900990099009901,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.09900990099009901,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.0297029702970297,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.009900990099009901,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.0594059405940594},"*MISSING_VALUE*":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":0.1,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.1,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":0.2,"*MISSING_VALUE*":0.1,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":0.4,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":0.1}},"skip":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.5225225225225225,"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.009009009009009009,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":0.16216216216216217,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":0.036036036036036036,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":0.009009009009009009,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":0.009009009009009009,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":0.09009009009009009,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":0.018018018018018018,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":0.02702702702702703,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":0.009009009009009009,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":0.05405405405405406,"*MISSING_VALUE*":0.009009009009009009,"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":0.036036036036036036,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":0.009009009009009009}}}}}}}},{"name":"accept-encoding","parentNames":["user-agent"],"possibleValues":["gzip, deflate, br, zstd","gzip, deflate, br","*MISSING_VALUE*","gzip"],"conditionalProbabilities":{"deeper":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":0.9977548271216884,"gzip, deflate, br":0.00224517287831163},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":0.9914899970140341,"gzip, deflate, br":0.00851000298596596},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"gzip, deflate, br, zstd":0.9878987898789879,"gzip, deflate, br":0.0121012101210121},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":{"gzip, deflate, br, zstd":0.9272237196765498,"gzip, deflate, br":0.07008086253369272,"*MISSING_VALUE*":0.0026954177897574125},"*MISSING_VALUE*":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/15E148 Safari/604.1":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15":{"gzip, deflate, br, zstd":0.8839285714285714,"gzip, deflate, br":0.11607142857142858},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1":{"gzip, deflate, br, zstd":0.07692307692307693,"gzip, deflate, br":0.9230769230769231},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15":{"gzip, deflate, br, zstd":0.9961089494163424,"gzip, deflate, br":0.0038910505836575876},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.4 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.5 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1 Brave":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15":{"gzip, deflate, br, zstd":0.9259259259259259,"gzip, deflate, br":0.07407407407407407},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36":{"gzip, deflate, br":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 Edg/147.0.0.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 Brave":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15":{"gzip, deflate, br":0.8888888888888888,"gzip, deflate, br, zstd":0.1111111111111111},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Android 16; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 EdgA/147.0.0.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.8909.1591 Mobile Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4450.0 Safari/537.36 LarkUrl":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36 EdgA/143.0.0.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36":{"gzip, deflate, br":0.1,"gzip, deflate, br, zstd":0.9},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 GrokApp/1.3.71":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36":{"gzip, deflate, br":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36":{"gzip, deflate, br":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36":{"gzip, deflate, br":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.56 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_15 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.2 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.138 Mobile Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (Android 13; Mobile; rv:136.0) Gecko/136.0 Firefox/136.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36":{"gzip":0.9166666666666666,"gzip, deflate, br, zstd":0.08333333333333333},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.235 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.9273.1293 Mobile Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/144.0.7559.95 Mobile/15E148 Safari/604.1":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/23D127 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.3 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Linux; Android 12; X16DzOXpOQ; U; en) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.63 Mobile Safari/537.36":{"gzip, deflate, br":1},"Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (Linux; Android 15; SM-G991W Build/AP3A.240905.015.A2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 CCleaner/146.0.34394.179":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.7 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0":{"gzip, deflate, br":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Mobile Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_14 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1 Brave":{"gzip, deflate, br":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (Linux; Android 15; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.73 Mobile Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/23D8133 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/23E261 Safari/604.1":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (iPad; CPU OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1 Brave":{"gzip, deflate, br":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.7 Mobile/22H340 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.2 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_8) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4.1 Mobile/22E252 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.3179.54":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Linux; Android 16; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.1 Mobile/15E148 Safari/604.1":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Atom/26.0.0.0 Safari/537.36":{"gzip, deflate, br":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1":{"gzip, deflate, br":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6788.76 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1}}}},{"name":"Accept-Encoding","parentNames":["User-Agent"],"possibleValues":["*MISSING_VALUE*","gzip, deflate, br","gzip, deflate, br, zstd","gzip, deflate","gzip, deflate, zstd","gzip, deflate, br, identity","gzip"],"conditionalProbabilities":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"gzip, deflate, br":0.08620689655172414,"gzip, deflate, br, zstd":0.5689655172413793,"gzip, deflate":0.1724137931034483,"gzip, deflate, zstd":0.10344827586206896,"gzip, deflate, br, identity":0.06896551724137931},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"gzip, deflate, br, zstd":0.8888888888888888,"gzip, deflate, br":0.1111111111111111},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":{"gzip, deflate, br":0.25,"gzip, deflate, br, zstd":0.75},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":0.8,"gzip, deflate, zstd":0.1,"gzip, deflate, br":0.1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":{"gzip, deflate, br":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"gzip, deflate, zstd":0.6666666666666666,"gzip, deflate, br, zstd":0.3333333333333333},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"gzip, deflate, br, zstd":0.6666666666666666,"gzip, deflate, br":0.3333333333333333},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":{"gzip, deflate, br":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":{"gzip":1}}}},{"name":"accept","parentNames":["user-agent"],"possibleValues":["text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7","text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8","text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8","*MISSING_VALUE*","text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7","text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9","text/html,application/xhtml+xml,application/xml;q=0.9,image/heif,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7","text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"],"conditionalProbabilities":{"deeper":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.8693309384822632,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.13022002694207455,"text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.000449034575662326},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.8884741713944461,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.11152582860555389},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.7028112449799196,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.2971887550200803},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.8035714285714286,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.19642857142857142},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":0.9964028776978417,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.0035971223021582736},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.07692307692307693,"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":0.9230769230769231},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.37777777777777777,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.6222222222222222},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.7674418604651163,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.23255813953488372},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":0.9473684210526315,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.05263157894736842},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.5,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.5},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.4 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.963963963963964,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.036036036036036036},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.5 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.8636363636363636,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.13636363636363635},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.8333333333333334,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.16666666666666666},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1 Brave":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.9333333333333333,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.06666666666666667},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.84,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.16},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.875,"text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.125},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 Edg/147.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 Brave":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.75,"text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.25},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Android 16; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 EdgA/147.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.9565217391304348,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.043478260869565216},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.8909.1591 Mobile Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/heif,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.5,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.5},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.8823529411764706,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.11764705882352941},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4450.0 Safari/537.36 LarkUrl":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/heif,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36 EdgA/143.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.6,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.4},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.6,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.4},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 GrokApp/1.3.71":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9":0.6,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.4},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.9,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.1},"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.56 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_15 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.2 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.138 Mobile Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Android 13; Mobile; rv:136.0) Gecko/136.0 Firefox/136.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.75,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.25},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.235 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.9273.1293 Mobile Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/heif,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/144.0.7559.95 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/23D127 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.5,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.5},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.3 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Linux; Android 12; X16DzOXpOQ; U; en) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.63 Mobile Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Linux; Android 15; SM-G991W Build/AP3A.240905.015.A2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 CCleaner/146.0.34394.179":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.7 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.75,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.25},"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Mobile Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_14 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":1},"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1 Brave":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Linux; Android 15; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.73 Mobile Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/23D8133 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/23E261 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPad; CPU OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1 Brave":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.7 Mobile/22H340 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.2 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_8) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4.1 Mobile/22E252 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.3179.54":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Linux; Android 16; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.1 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Atom/26.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6788.76 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":1}}}},{"name":"Accept","parentNames":["User-Agent"],"possibleValues":["*MISSING_VALUE*","text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7","text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8","text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"],"conditionalProbabilities":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.9655172413793104,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.034482758620689655},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":0.9,"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8":0.1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":{"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7":1}}}},{"name":"dnt","parentNames":["user-agent"],"possibleValues":["*MISSING_VALUE*","1"],"conditionalProbabilities":{"deeper":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"1":0.08576560395150426,"*MISSING_VALUE*":0.9142343960484958},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"1":0.074499850701702,"*MISSING_VALUE*":0.925500149298298},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"1":0.10121012101210121,"*MISSING_VALUE*":0.8987898789878987},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":{"1":0.1566265060240964,"*MISSING_VALUE*":0.8433734939759037},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"1":0.060714285714285714,"*MISSING_VALUE*":0.9392857142857143},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"1":0.13333333333333333,"*MISSING_VALUE*":0.8666666666666667},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"1":0.09302325581395349,"*MISSING_VALUE*":0.9069767441860465},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0":{"1":0.42105263157894735,"*MISSING_VALUE*":0.5789473684210527},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"1":0.1,"*MISSING_VALUE*":0.9},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"1":0.041666666666666664,"*MISSING_VALUE*":0.9583333333333334},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.4 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"1":0.05405405405405406,"*MISSING_VALUE*":0.9459459459459459},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.5 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"1":0.04672897196261682,"*MISSING_VALUE*":0.9532710280373832},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"1":0.049019607843137254,"*MISSING_VALUE*":0.9509803921568627},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"1":0.038461538461538464,"*MISSING_VALUE*":0.9615384615384616},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1 Brave":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"1":0.06666666666666667,"*MISSING_VALUE*":0.9333333333333333},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"1":0.12,"*MISSING_VALUE*":0.88},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"1":0.10714285714285714,"*MISSING_VALUE*":0.8928571428571429},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0":{"1":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"1":0.125,"*MISSING_VALUE*":0.875},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36":{"1":0.2,"*MISSING_VALUE*":0.8},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":{"1":0.09090909090909091,"*MISSING_VALUE*":0.9090909090909091},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"1":0.38095238095238093,"*MISSING_VALUE*":0.6190476190476191},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 Edg/147.0.0.0":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 Brave":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"1":0.25,"*MISSING_VALUE*":0.75},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0":{"1":1},"Mozilla/5.0 (Android 16; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0":{"1":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 EdgA/147.0.0.0":{"1":0.2,"*MISSING_VALUE*":0.8},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"1":0.1111111111111111,"*MISSING_VALUE*":0.8888888888888888},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"1":0.13043478260869565,"*MISSING_VALUE*":0.8695652173913043},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.8909.1591 Mobile Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"1":0.25,"*MISSING_VALUE*":0.75},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"1":0.058823529411764705,"*MISSING_VALUE*":0.9411764705882353},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4450.0 Safari/537.36 LarkUrl":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36 EdgA/143.0.0.0":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"1":0.6,"*MISSING_VALUE*":0.4},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 GrokApp/1.3.71":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"1":0.5,"*MISSING_VALUE*":0.5},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":{"1":1},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"1":0.125,"*MISSING_VALUE*":0.875},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0":{"1":0.1111111111111111,"*MISSING_VALUE*":0.8888888888888888},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.56 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_15 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.2 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.138 Mobile Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"1":0.5714285714285714,"*MISSING_VALUE*":0.42857142857142855},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Android 13; Mobile; rv:136.0) Gecko/136.0 Firefox/136.0":{"1":1},"Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.235 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.9273.1293 Mobile Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/144.0.7559.95 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/23D127 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.3 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 12; X16DzOXpOQ; U; en) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.63 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0":{"1":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":{"1":0.5,"*MISSING_VALUE*":0.5},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 15; SM-G991W Build/AP3A.240905.015.A2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 CCleaner/146.0.34394.179":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.7 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"1":0.25,"*MISSING_VALUE*":0.75},"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Mobile Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_14 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":{"1":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1 Brave":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 15; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.73 Mobile Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/23D8133 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/23E261 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPad; CPU OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1 Brave":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.7 Mobile/22H340 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.2 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_8) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4.1 Mobile/22E252 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.3179.54":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 16; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.1 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Atom/26.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"1":0.3333333333333333,"*MISSING_VALUE*":0.6666666666666666},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6788.76 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1}}}},{"name":"DNT","parentNames":["User-Agent"],"possibleValues":["*MISSING_VALUE*","1"],"conditionalProbabilities":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"1":0.06896551724137931,"*MISSING_VALUE*":0.9310344827586207},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"1":0.05555555555555555,"*MISSING_VALUE*":0.9444444444444444},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":{"1":0.75,"*MISSING_VALUE*":0.25},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1}}}},{"name":"sec-ch-ua","parentNames":["user-agent","User-Agent","sec-ch-ua-mobile"],"possibleValues":["\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"","\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"","\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"","*MISSING_VALUE*","\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"","\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"","\"HeadlessChrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"","\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"","\"Chromium\";v=\"147\", \"Not:A-Brand\";v=\"8\", \"Google Chrome\";v=\"147\"","\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"","\"Not_A Brand\";v=\"99\", \"Chromium\";v=\"142\"","\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Brave\";v=\"144\"","\"Microsoft Edge\";v=\"129\", \"Not=A?Brand\";v=\"8\", \"Chromium\";v=\"129\"","\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"","\"Not)A;Brand\";v=\"99\", \"Google Chrome\";v=\"127\", \"Chromium\";v=\"127\"","\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Google Chrome\";v=\"144\"","\"Google Chrome\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"","\"Not A(Brand\";v=\"99\", \"Google Chrome\";v=\"121\", \"Chromium\";v=\"121\"","\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"","\"Chromium\";v=\"128\", \"Not;A=Brand\";v=\"24\", \"Google Chrome\";v=\"128\"","\"Not-A.Brand\";v=\"24\", \"Chromium\";v=\"146\"","\"Chromium\";v=\"116\", \"Not)A;Brand\";v=\"24\", \"Google Chrome\";v=\"116\"","\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"","\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"","\"Not/A)Brand\";v=\"99\", \"Google Chrome\";v=\"115\", \"Chromium\";v=\"115\"","\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Microsoft Edge\";v=\"146\"","\"Google Chrome\";v=\"149\", \"Chromium\";v=\"149\", \"Not)A;Brand\";v=\"24\"","\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"","\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\"","\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Microsoft Edge\";v=\"144\"","\"Chromium\";v=\"128\", \"Not;A=Brand\";v=\"24\", \"Microsoft Edge\";v=\"128\"","\"Not_A Brand\";v=\"99\", \"Google Chrome\";v=\"109\", \"Chromium\";v=\"109\"","\"Not;A=Brand\";v=\"99\", \"Google Chrome\";v=\"139\", \"Chromium\";v=\"139\"","\"Microsoft Edge\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"","\"Brave\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"","\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"","\"Chromium\";v=\"134\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"134\"","\"Chromium\";v=\"145\", \"Not:A-Brand\";v=\"99\"","\"Chromium\";v=\"130\", \"Google Chrome\";v=\"130\", \"Not?A_Brand\";v=\"99\"","\"HeadlessChrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"","\"Google Chrome\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"","\"Not:A-Brand\";v=\"99\", \"Microsoft Edge\";v=\"145\", \"Chromium\";v=\"145\"","\"Chromium\";v=\"135\", \"Not-A.Brand\";v=\"8\"","\"Google Chrome\";v=\"144\", \"Chromium\";v=\"144\", \"Not(A:Brand\";v=\"99\"","\"Google Chrome\";v=\"125\", \"Chromium\";v=\"125\", \" Not;A Brand\";v=\"24\"","\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\"","\"Google Chrome\";v=\"113\", \"Chromium\";v=\"113\", \"Not-A.Brand\";v=\"24\"","\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"","\"Not:A-Brand\";v=\"99\", \"HeadlessChrome\";v=\"145\", \"Chromium\";v=\"145\"","\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\"","\"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"108\", \"Google Chrome\";v=\"108\"","\"Google Chrome\";v=\"125\", \"Chromium\";v=\"125\", \"Not.A/Brand\";v=\"24\"","\"Chromium\";v=\"148\", \"Microsoft Edge\";v=\"148\", \"Not/A)Brand\";v=\"99\"","\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"147\", \"Google Chrome\";v=\"147\"","\"Microsoft Edge\";v=\"123\", \"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"123\"","\"Chromium\";v=\"136\", \"Google Chrome\";v=\"136\", \"Not.A/Brand\";v=\"99\"","\"Google Chrome\";v=\"119\", \"Chromium\";v=\"119\", \"Not?A_Brand\";v=\"24\"","\"Chromium\";v=\"142\", \"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"142\"","\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Microsoft Edge\";v=\"138\"","\"Chromium\";v=\"146\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"146\"","\"Google Chrome\";v=\"135\", \"Not-A.Brand\";v=\"8\", \"Chromium\";v=\"135\"","\"Brave\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"","\"Chromium\";v=\"142\", \"Microsoft Edge\";v=\"142\", \"Not_A Brand\";v=\"99\"","\"Chromium\";v=\"143\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"143\"","\"Not:A-Brand\";v=\"24\", \"Chromium\";v=\"134\"","\"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\", \"Chromium\";v=\"146\", \"Vivaldi\";v=\"7.9\"","\"Android WebView\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"","\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99\"","\"Chromium\";v=\"122\", \"Not(A:Brand\";v=\"24\", \"Google Chrome\";v=\"122\"","\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"CCleaner Browser\";v=\"146\"","\"Chromium\";v=\"122\", \"Not(A:Brand\";v=\"24\", \"Microsoft Edge\";v=\"122\"","\".Not/A)Brand\";v=\"99\", \"Google Chrome\";v=\"103\", \"Chromium\";v=\"103\"","\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Vivaldi\";v=\"7.9\"","\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"","\"Chromium\";v=\"131\", \"Google Chrome\";v=\"131\", \"Not_A Brand\";v=\"24\"","\"Chromium\";v=\"132\", \"Google Chrome\";v=\"132\", \"Not_A Brand\";v=\"24\"","\"Chromium\";v=\"130\", \"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"130\"","\"Not(A:Brand\";v=\"99\", \"Google Chrome\";v=\"133\", \"Chromium\";v=\"133\"","\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\"","\"Not-A.Brand\";v=\"24\", \"Chromium\";v=\"146\", \"DuckDuckGo\";v=\"146\"","\"Chromium\";v=\"142\", \"Brave\";v=\"142\", \"Not_A Brand\";v=\"99\"","\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"WaveBrowser\";v=\"138\"","\"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"","\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"","\"Chromium\";v=\"127\", \"Not)A;Brand\";v=\"99\"","\"Chromium\";v=\"106\", \"Atom\";v=\"26\", \"Not;A=Brand\";v=\"99\"","\"Not A(Brand\";v=\"8\", \"Chromium\";v=\"132\", \"Google Chrome\";v=\"132\"","\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"132\", \"Google Chrome\";v=\"132\"","\"Tabbit\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"","\"Island\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\""],"conditionalProbabilities":{"deeper":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.8222322232223223,"\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"":0.04590459045904591,"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.13051305130513052,"\"Tabbit\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.0009000900090009,"\"Island\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.00045004500450045},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.8203861697350696,"\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"":0.04580152671755725,"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.13022002694207455,"*MISSING_VALUE*":0.00224517287831163,"\"Tabbit\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.000898069151324652,"\"Island\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.000449034575662326}}},"skip":{"deeper":{"?0":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.8222322232223223,"\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"":0.04590459045904591,"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.13051305130513052,"\"Tabbit\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.0009000900090009,"\"Island\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.00045004500450045},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.8203861697350696,"\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"":0.04580152671755725,"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.13022002694207455,"*MISSING_VALUE*":0.00224517287831163,"\"Tabbit\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.000898069151324652,"\"Island\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.000449034575662326}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.8865332935204538,"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.1113765303075545,"\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"":0.0020901761719916393}},"skip":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.8865332935204538,"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.1113765303075545,"\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"":0.0020901761719916393}}},"skip":{"deeper":{"?0":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.8865332935204538,"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.1113765303075545,"\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"":0.0020901761719916393}},"skip":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.8865332935204538,"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.1113765303075545,"\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"":0.0020901761719916393}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}},"skip":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}}},"skip":{"deeper":{"?0":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}},"skip":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"*MISSING_VALUE*":{"deeper":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"deeper":{"?0":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.9655172413793104,"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.034482758620689655}},"skip":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.9655172413793104,"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.034482758620689655}},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"deeper":{"?0":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}},"skip":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"deeper":{"?0":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.9444444444444444,"*MISSING_VALUE*":0.05555555555555555}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":{"deeper":{"?0":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Microsoft Edge\";v=\"146\"":1}},"skip":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Microsoft Edge\";v=\"146\"":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"deeper":{"?0":{"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":1}},"skip":{"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"deeper":{"?0":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.9,"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.1}},"skip":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.9,"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"deeper":{"?0":{"\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"":1}},"skip":{"\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"deeper":{"?0":{"\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"deeper":{"?0":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":1}},"skip":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"?0":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.6534653465346535,"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.16831683168316833,"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.0297029702970297,"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Microsoft Edge\";v=\"146\"":0.039603960396039604,"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.009900990099009901,"\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"":0.0297029702970297,"\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"":0.009900990099009901,"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":0.0594059405940594},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.6,"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.15454545454545454,"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.02727272727272727,"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Microsoft Edge\";v=\"146\"":0.03636363636363636,"*MISSING_VALUE*":0.08181818181818182,"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.00909090909090909,"\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"":0.02727272727272727,"\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"":0.00909090909090909,"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":0.05454545454545454}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?1":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.6746987951807228,"\"Chromium\";v=\"147\", \"Not:A-Brand\";v=\"8\", \"Google Chrome\";v=\"147\"":0.024096385542168676,"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.2971887550200803,"\"Android WebView\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.004016064257028112}},"skip":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.6746987951807228,"\"Chromium\";v=\"147\", \"Not:A-Brand\";v=\"8\", \"Google Chrome\";v=\"147\"":0.024096385542168676,"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.2971887550200803,"\"Android WebView\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.004016064257028112}}},"skip":{"deeper":{"?1":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.6746987951807228,"\"Chromium\";v=\"147\", \"Not:A-Brand\";v=\"8\", \"Google Chrome\";v=\"147\"":0.024096385542168676,"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.2971887550200803,"\"Android WebView\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.004016064257028112}},"skip":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.6746987951807228,"\"Chromium\";v=\"147\", \"Not:A-Brand\";v=\"8\", \"Google Chrome\";v=\"147\"":0.024096385542168676,"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.2971887550200803,"\"Android WebView\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.004016064257028112}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":0.7607142857142857,"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"":0.19642857142857142,"\"Not-A.Brand\";v=\"24\", \"Chromium\";v=\"146\"":0.04285714285714286}},"skip":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":0.7607142857142857,"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"":0.19642857142857142,"\"Not-A.Brand\";v=\"24\", \"Chromium\";v=\"146\"":0.04285714285714286}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":0.7607142857142857,"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"":0.19642857142857142,"\"Not-A.Brand\";v=\"24\", \"Chromium\";v=\"146\"":0.04285714285714286}},"skip":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":0.7607142857142857,"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"":0.19642857142857142,"\"Not-A.Brand\";v=\"24\", \"Chromium\";v=\"146\"":0.04285714285714286}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"HeadlessChrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":0.9964028776978417,"\"HeadlessChrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.0035971223021582736}}},"skip":{"deeper":{"?0":{"\"HeadlessChrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":0.9964028776978417,"\"HeadlessChrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.0035971223021582736}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"HeadlessChrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"\"HeadlessChrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.07692307692307693,"*MISSING_VALUE*":0.9230769230769231}}},"skip":{"deeper":{"?0":{"\"HeadlessChrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"\"HeadlessChrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.07692307692307693,"*MISSING_VALUE*":0.9230769230769231}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.37777777777777777,"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.5777777777777777,"\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"":0.03888888888888889,"\"HeadlessChrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.005555555555555556}},"skip":{"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.37777777777777777,"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.5777777777777777,"\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"":0.03888888888888889,"\"HeadlessChrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.005555555555555556}}},"skip":{"deeper":{"?0":{"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.37777777777777777,"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.5777777777777777,"\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"":0.03888888888888889,"\"HeadlessChrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.005555555555555556}},"skip":{"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.37777777777777777,"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.5777777777777777,"\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"":0.03888888888888889,"\"HeadlessChrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.005555555555555556}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":0.7441860465116279,"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"":0.23255813953488372,"\"Not-A.Brand\";v=\"24\", \"Chromium\";v=\"146\"":0.023255813953488372}},"skip":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":0.7441860465116279,"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"":0.23255813953488372,"\"Not-A.Brand\";v=\"24\", \"Chromium\";v=\"146\"":0.023255813953488372}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":0.7441860465116279,"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"":0.23255813953488372,"\"Not-A.Brand\";v=\"24\", \"Chromium\";v=\"146\"":0.023255813953488372}},"skip":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":0.7441860465116279,"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"":0.23255813953488372,"\"Not-A.Brand\";v=\"24\", \"Chromium\";v=\"146\"":0.023255813953488372}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"HeadlessChrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":0.9473684210526315,"\"HeadlessChrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.05263157894736842}}},"skip":{"deeper":{"?0":{"\"HeadlessChrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":0.9473684210526315,"\"HeadlessChrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.05263157894736842}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not_A Brand\";v=\"99\", \"Chromium\";v=\"142\"":0.9223300970873787,"\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"":0.07766990291262135}},"skip":{"\"Not_A Brand\";v=\"99\", \"Chromium\";v=\"142\"":0.9223300970873787,"\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"":0.07766990291262135}}},"skip":{"deeper":{"?0":{"\"Not_A Brand\";v=\"99\", \"Chromium\";v=\"142\"":0.9223300970873787,"\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"":0.07766990291262135}},"skip":{"\"Not_A Brand\";v=\"99\", \"Chromium\";v=\"142\"":0.9223300970873787,"\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"":0.07766990291262135}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Brave\";v=\"144\"":0.5,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Google Chrome\";v=\"144\"":0.4583333333333333,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\"":0.041666666666666664}},"skip":{"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Brave\";v=\"144\"":0.5,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Google Chrome\";v=\"144\"":0.4583333333333333,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\"":0.041666666666666664}}},"skip":{"deeper":{"?0":{"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Brave\";v=\"144\"":0.5,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Google Chrome\";v=\"144\"":0.4583333333333333,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\"":0.041666666666666664}},"skip":{"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Brave\";v=\"144\"":0.5,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Google Chrome\";v=\"144\"":0.4583333333333333,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\"":0.041666666666666664}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Microsoft Edge\";v=\"129\", \"Not=A?Brand\";v=\"8\", \"Chromium\";v=\"129\"":1}},"skip":{"\"Microsoft Edge\";v=\"129\", \"Not=A?Brand\";v=\"8\", \"Chromium\";v=\"129\"":1}}},"skip":{"deeper":{"?0":{"\"Microsoft Edge\";v=\"129\", \"Not=A?Brand\";v=\"8\", \"Chromium\";v=\"129\"":1}},"skip":{"\"Microsoft Edge\";v=\"129\", \"Not=A?Brand\";v=\"8\", \"Chromium\";v=\"129\"":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.4 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":0.8818181818181818,"\"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\", \"Chromium\";v=\"146\", \"Vivaldi\";v=\"7.9\"":0.00909090909090909,"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Vivaldi\";v=\"7.9\"":0.01818181818181818,"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"":0.03636363636363636,"\"Not-A.Brand\";v=\"24\", \"Chromium\";v=\"146\", \"DuckDuckGo\";v=\"146\"":0.03636363636363636,"\"Not-A.Brand\";v=\"24\", \"Chromium\";v=\"146\"":0.01818181818181818},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":0.8738738738738738,"*MISSING_VALUE*":0.009009009009009009,"\"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\", \"Chromium\";v=\"146\", \"Vivaldi\";v=\"7.9\"":0.009009009009009009,"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Vivaldi\";v=\"7.9\"":0.018018018018018018,"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"":0.036036036036036036,"\"Not-A.Brand\";v=\"24\", \"Chromium\";v=\"146\", \"DuckDuckGo\";v=\"146\"":0.036036036036036036,"\"Not-A.Brand\";v=\"24\", \"Chromium\";v=\"146\"":0.018018018018018018}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":0.8818181818181818,"\"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\", \"Chromium\";v=\"146\", \"Vivaldi\";v=\"7.9\"":0.00909090909090909,"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Vivaldi\";v=\"7.9\"":0.01818181818181818,"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"":0.03636363636363636,"\"Not-A.Brand\";v=\"24\", \"Chromium\";v=\"146\", \"DuckDuckGo\";v=\"146\"":0.03636363636363636,"\"Not-A.Brand\";v=\"24\", \"Chromium\";v=\"146\"":0.01818181818181818},"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":0.8738738738738738,"*MISSING_VALUE*":0.009009009009009009,"\"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\", \"Chromium\";v=\"146\", \"Vivaldi\";v=\"7.9\"":0.009009009009009009,"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Vivaldi\";v=\"7.9\"":0.018018018018018018,"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"":0.036036036036036036,"\"Not-A.Brand\";v=\"24\", \"Chromium\";v=\"146\", \"DuckDuckGo\";v=\"146\"":0.036036036036036036,"\"Not-A.Brand\";v=\"24\", \"Chromium\";v=\"146\"":0.018018018018018018}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.5 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"":0.8636363636363636,"\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\"":0.13636363636363635}},"skip":{"\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"":0.8636363636363636,"\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\"":0.13636363636363635}}},"skip":{"deeper":{"?0":{"\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"":0.8636363636363636,"\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\"":0.13636363636363635}},"skip":{"\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"":0.8636363636363636,"\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\"":0.13636363636363635}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not)A;Brand\";v=\"99\", \"Google Chrome\";v=\"127\", \"Chromium\";v=\"127\"":1}},"skip":{"\"Not)A;Brand\";v=\"99\", \"Google Chrome\";v=\"127\", \"Chromium\";v=\"127\"":1}}},"skip":{"deeper":{"?0":{"\"Not)A;Brand\";v=\"99\", \"Google Chrome\";v=\"127\", \"Chromium\";v=\"127\"":1}},"skip":{"\"Not)A;Brand\";v=\"99\", \"Google Chrome\";v=\"127\", \"Chromium\";v=\"127\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"":1}},"skip":{"\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"":1}}},"skip":{"deeper":{"?0":{"\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"":1}},"skip":{"\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"":0.7647058823529411,"\"Chromium\";v=\"145\", \"Not:A-Brand\";v=\"99\"":0.06862745098039216,"\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\"":0.16666666666666666}},"skip":{"\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"":0.7647058823529411,"\"Chromium\";v=\"145\", \"Not:A-Brand\";v=\"99\"":0.06862745098039216,"\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\"":0.16666666666666666}}},"skip":{"deeper":{"?0":{"\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"":0.7647058823529411,"\"Chromium\";v=\"145\", \"Not:A-Brand\";v=\"99\"":0.06862745098039216,"\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\"":0.16666666666666666}},"skip":{"\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"":0.7647058823529411,"\"Chromium\";v=\"145\", \"Not:A-Brand\";v=\"99\"":0.06862745098039216,"\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\"":0.16666666666666666}}},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?1":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}},"skip":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}}},"skip":{"deeper":{"?1":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}},"skip":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1 Brave":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Google Chrome\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"":1}},"skip":{"\"Google Chrome\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"":1}}},"skip":{"deeper":{"?0":{"\"Google Chrome\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"":1}},"skip":{"\"Google Chrome\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not A(Brand\";v=\"99\", \"Google Chrome\";v=\"121\", \"Chromium\";v=\"121\"":1}},"skip":{"\"Not A(Brand\";v=\"99\", \"Google Chrome\";v=\"121\", \"Chromium\";v=\"121\"":1}}},"skip":{"deeper":{"?0":{"\"Not A(Brand\";v=\"99\", \"Google Chrome\";v=\"121\", \"Chromium\";v=\"121\"":1}},"skip":{"\"Not A(Brand\";v=\"99\", \"Google Chrome\";v=\"121\", \"Chromium\";v=\"121\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.84,"\"Brave\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.16}},"skip":{"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.84,"\"Brave\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.16}}},"skip":{"deeper":{"?0":{"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.84,"\"Brave\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.16}},"skip":{"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.84,"\"Brave\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.16}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"128\", \"Not;A=Brand\";v=\"24\", \"Google Chrome\";v=\"128\"":1}},"skip":{"\"Chromium\";v=\"128\", \"Not;A=Brand\";v=\"24\", \"Google Chrome\";v=\"128\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"128\", \"Not;A=Brand\";v=\"24\", \"Google Chrome\";v=\"128\"":1}},"skip":{"\"Chromium\";v=\"128\", \"Not;A=Brand\";v=\"24\", \"Google Chrome\";v=\"128\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"116\", \"Not)A;Brand\";v=\"24\", \"Google Chrome\";v=\"116\"":1}},"skip":{"\"Chromium\";v=\"116\", \"Not)A;Brand\";v=\"24\", \"Google Chrome\";v=\"116\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"116\", \"Not)A;Brand\";v=\"24\", \"Google Chrome\";v=\"116\"":1}},"skip":{"\"Chromium\";v=\"116\", \"Not)A;Brand\";v=\"24\", \"Google Chrome\";v=\"116\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"":1}},"skip":{"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"":1}}},"skip":{"deeper":{"?0":{"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"":1}},"skip":{"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"":1}},"skip":{"\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"":1}}},"skip":{"deeper":{"?0":{"\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"":1}},"skip":{"\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not/A)Brand\";v=\"99\", \"Google Chrome\";v=\"115\", \"Chromium\";v=\"115\"":1}},"skip":{"\"Not/A)Brand\";v=\"99\", \"Google Chrome\";v=\"115\", \"Chromium\";v=\"115\"":1}}},"skip":{"deeper":{"?0":{"\"Not/A)Brand\";v=\"99\", \"Google Chrome\";v=\"115\", \"Chromium\";v=\"115\"":1}},"skip":{"\"Not/A)Brand\";v=\"99\", \"Google Chrome\";v=\"115\", \"Chromium\";v=\"115\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Microsoft Edge\";v=\"146\"":1}},"skip":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Microsoft Edge\";v=\"146\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Microsoft Edge\";v=\"146\"":1}},"skip":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Microsoft Edge\";v=\"146\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Google Chrome\";v=\"149\", \"Chromium\";v=\"149\", \"Not)A;Brand\";v=\"24\"":1}},"skip":{"\"Google Chrome\";v=\"149\", \"Chromium\";v=\"149\", \"Not)A;Brand\";v=\"24\"":1}}},"skip":{"deeper":{"?0":{"\"Google Chrome\";v=\"149\", \"Chromium\";v=\"149\", \"Not)A;Brand\";v=\"24\"":1}},"skip":{"\"Google Chrome\";v=\"149\", \"Chromium\";v=\"149\", \"Not)A;Brand\";v=\"24\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"":0.8125,"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\"":0.125,"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"WaveBrowser\";v=\"138\"":0.0625}},"skip":{"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"":0.8125,"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\"":0.125,"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"WaveBrowser\";v=\"138\"":0.0625}}},"skip":{"deeper":{"?0":{"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"":0.8125,"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\"":0.125,"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"WaveBrowser\";v=\"138\"":0.0625}},"skip":{"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"":0.8125,"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\"":0.125,"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"WaveBrowser\";v=\"138\"":0.0625}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"":1}},"skip":{"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"":1}},"skip":{"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Microsoft Edge\";v=\"144\"":1}},"skip":{"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Microsoft Edge\";v=\"144\"":1}}},"skip":{"deeper":{"?0":{"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Microsoft Edge\";v=\"144\"":1}},"skip":{"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Microsoft Edge\";v=\"144\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"128\", \"Not;A=Brand\";v=\"24\", \"Microsoft Edge\";v=\"128\"":1}},"skip":{"\"Chromium\";v=\"128\", \"Not;A=Brand\";v=\"24\", \"Microsoft Edge\";v=\"128\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"128\", \"Not;A=Brand\";v=\"24\", \"Microsoft Edge\";v=\"128\"":1}},"skip":{"\"Chromium\";v=\"128\", \"Not;A=Brand\";v=\"24\", \"Microsoft Edge\";v=\"128\"":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?1":{"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"":1}},"skip":{"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"":1}}},"skip":{"deeper":{"?1":{"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"":1}},"skip":{"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not_A Brand\";v=\"99\", \"Google Chrome\";v=\"109\", \"Chromium\";v=\"109\"":1}},"skip":{"\"Not_A Brand\";v=\"99\", \"Google Chrome\";v=\"109\", \"Chromium\";v=\"109\"":1}}},"skip":{"deeper":{"?0":{"\"Not_A Brand\";v=\"99\", \"Google Chrome\";v=\"109\", \"Chromium\";v=\"109\"":1}},"skip":{"\"Not_A Brand\";v=\"99\", \"Google Chrome\";v=\"109\", \"Chromium\";v=\"109\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not;A=Brand\";v=\"99\", \"Google Chrome\";v=\"139\", \"Chromium\";v=\"139\"":1}},"skip":{"\"Not;A=Brand\";v=\"99\", \"Google Chrome\";v=\"139\", \"Chromium\";v=\"139\"":1}}},"skip":{"deeper":{"?0":{"\"Not;A=Brand\";v=\"99\", \"Google Chrome\";v=\"139\", \"Chromium\";v=\"139\"":1}},"skip":{"\"Not;A=Brand\";v=\"99\", \"Google Chrome\";v=\"139\", \"Chromium\";v=\"139\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Microsoft Edge\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":1}},"skip":{"\"Microsoft Edge\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":1}}},"skip":{"deeper":{"?0":{"\"Microsoft Edge\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":1}},"skip":{"\"Microsoft Edge\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"":0.75,"\"Not:A-Brand\";v=\"99\", \"HeadlessChrome\";v=\"145\", \"Chromium\";v=\"145\"":0.25}},"skip":{"\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"":0.75,"\"Not:A-Brand\";v=\"99\", \"HeadlessChrome\";v=\"145\", \"Chromium\";v=\"145\"":0.25}}},"skip":{"deeper":{"?0":{"\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"":0.75,"\"Not:A-Brand\";v=\"99\", \"HeadlessChrome\";v=\"145\", \"Chromium\";v=\"145\"":0.25}},"skip":{"\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"":0.75,"\"Not:A-Brand\";v=\"99\", \"HeadlessChrome\";v=\"145\", \"Chromium\";v=\"145\"":0.25}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}},"skip":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}}},"skip":{"deeper":{"?0":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}},"skip":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"134\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"134\"":1}},"skip":{"\"Chromium\";v=\"134\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"134\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"134\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"134\"":1}},"skip":{"\"Chromium\";v=\"134\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"134\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 Edg/147.0.0.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?1":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}},"skip":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}}},"skip":{"deeper":{"?1":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}},"skip":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 Brave":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"130\", \"Google Chrome\";v=\"130\", \"Not?A_Brand\";v=\"99\"":0.75,"\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"":0.25}},"skip":{"\"Chromium\";v=\"130\", \"Google Chrome\";v=\"130\", \"Not?A_Brand\";v=\"99\"":0.75,"\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"":0.25}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"130\", \"Google Chrome\";v=\"130\", \"Not?A_Brand\";v=\"99\"":0.75,"\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"":0.25}},"skip":{"\"Chromium\";v=\"130\", \"Google Chrome\";v=\"130\", \"Not?A_Brand\";v=\"99\"":0.75,"\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"":0.25}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"HeadlessChrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.6666666666666666,"\"Not:A-Brand\";v=\"99\", \"HeadlessChrome\";v=\"145\", \"Chromium\";v=\"145\"":0.3333333333333333}},"skip":{"\"HeadlessChrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.6666666666666666,"\"Not:A-Brand\";v=\"99\", \"HeadlessChrome\";v=\"145\", \"Chromium\";v=\"145\"":0.3333333333333333}}},"skip":{"deeper":{"?0":{"\"HeadlessChrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.6666666666666666,"\"Not:A-Brand\";v=\"99\", \"HeadlessChrome\";v=\"145\", \"Chromium\";v=\"145\"":0.3333333333333333}},"skip":{"\"HeadlessChrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.6666666666666666,"\"Not:A-Brand\";v=\"99\", \"HeadlessChrome\";v=\"145\", \"Chromium\";v=\"145\"":0.3333333333333333}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Google Chrome\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"":0.875,"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.125}},"skip":{"\"Google Chrome\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"":0.875,"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.125}}},"skip":{"deeper":{"?0":{"\"Google Chrome\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"":0.875,"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.125}},"skip":{"\"Google Chrome\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"":0.875,"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.125}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"":1}},"skip":{"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"":1}},"skip":{"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Android 16; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 EdgA/147.0.0.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?1":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}},"skip":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}}},"skip":{"deeper":{"?1":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}},"skip":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not:A-Brand\";v=\"99\", \"Microsoft Edge\";v=\"145\", \"Chromium\";v=\"145\"":1}},"skip":{"\"Not:A-Brand\";v=\"99\", \"Microsoft Edge\";v=\"145\", \"Chromium\";v=\"145\"":1}}},"skip":{"deeper":{"?0":{"\"Not:A-Brand\";v=\"99\", \"Microsoft Edge\";v=\"145\", \"Chromium\";v=\"145\"":1}},"skip":{"\"Not:A-Brand\";v=\"99\", \"Microsoft Edge\";v=\"145\", \"Chromium\";v=\"145\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"135\", \"Not-A.Brand\";v=\"8\"":0.6666666666666666,"\"Google Chrome\";v=\"135\", \"Not-A.Brand\";v=\"8\", \"Chromium\";v=\"135\"":0.3333333333333333}},"skip":{"\"Chromium\";v=\"135\", \"Not-A.Brand\";v=\"8\"":0.6666666666666666,"\"Google Chrome\";v=\"135\", \"Not-A.Brand\";v=\"8\", \"Chromium\";v=\"135\"":0.3333333333333333}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"135\", \"Not-A.Brand\";v=\"8\"":0.6666666666666666,"\"Google Chrome\";v=\"135\", \"Not-A.Brand\";v=\"8\", \"Chromium\";v=\"135\"":0.3333333333333333}},"skip":{"\"Chromium\";v=\"135\", \"Not-A.Brand\";v=\"8\"":0.6666666666666666,"\"Google Chrome\";v=\"135\", \"Not-A.Brand\";v=\"8\", \"Chromium\";v=\"135\"":0.3333333333333333}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.9565217391304348,"\"Brave\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.043478260869565216}},"skip":{"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.9565217391304348,"\"Brave\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.043478260869565216}}},"skip":{"deeper":{"?0":{"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.9565217391304348,"\"Brave\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.043478260869565216}},"skip":{"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.9565217391304348,"\"Brave\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.043478260869565216}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.8909.1591 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.5,"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.5}},"skip":{"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.5,"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.5}}},"skip":{"deeper":{"?0":{"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.5,"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.5}},"skip":{"\"Brave\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.5,"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.5}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Google Chrome\";v=\"144\", \"Chromium\";v=\"144\", \"Not(A:Brand\";v=\"99\"":0.11764705882352941,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Google Chrome\";v=\"144\"":0.7058823529411765,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Brave\";v=\"144\"":0.11764705882352941,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\"":0.058823529411764705}},"skip":{"\"Google Chrome\";v=\"144\", \"Chromium\";v=\"144\", \"Not(A:Brand\";v=\"99\"":0.11764705882352941,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Google Chrome\";v=\"144\"":0.7058823529411765,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Brave\";v=\"144\"":0.11764705882352941,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\"":0.058823529411764705}}},"skip":{"deeper":{"?0":{"\"Google Chrome\";v=\"144\", \"Chromium\";v=\"144\", \"Not(A:Brand\";v=\"99\"":0.11764705882352941,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Google Chrome\";v=\"144\"":0.7058823529411765,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Brave\";v=\"144\"":0.11764705882352941,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\"":0.058823529411764705}},"skip":{"\"Google Chrome\";v=\"144\", \"Chromium\";v=\"144\", \"Not(A:Brand\";v=\"99\"":0.11764705882352941,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Google Chrome\";v=\"144\"":0.7058823529411765,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Brave\";v=\"144\"":0.11764705882352941,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\"":0.058823529411764705}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4450.0 Safari/537.36 LarkUrl":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36 EdgA/143.0.0.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?1":{"\"Microsoft Edge\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":1}},"skip":{"\"Microsoft Edge\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":1}}},"skip":{"deeper":{"?1":{"\"Microsoft Edge\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":1}},"skip":{"\"Microsoft Edge\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Google Chrome\";v=\"125\", \"Chromium\";v=\"125\", \" Not;A Brand\";v=\"24\"":0.2,"\"Google Chrome\";v=\"125\", \"Chromium\";v=\"125\", \"Not.A/Brand\";v=\"24\"":0.4,"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.4}},"skip":{"\"Google Chrome\";v=\"125\", \"Chromium\";v=\"125\", \" Not;A Brand\";v=\"24\"":0.2,"\"Google Chrome\";v=\"125\", \"Chromium\";v=\"125\", \"Not.A/Brand\";v=\"24\"":0.4,"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.4}}},"skip":{"deeper":{"?0":{"\"Google Chrome\";v=\"125\", \"Chromium\";v=\"125\", \" Not;A Brand\";v=\"24\"":0.2,"\"Google Chrome\";v=\"125\", \"Chromium\";v=\"125\", \"Not.A/Brand\";v=\"24\"":0.4,"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.4}},"skip":{"\"Google Chrome\";v=\"125\", \"Chromium\";v=\"125\", \" Not;A Brand\";v=\"24\"":0.2,"\"Google Chrome\";v=\"125\", \"Chromium\";v=\"125\", \"Not.A/Brand\";v=\"24\"":0.4,"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":0.4}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Brave\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.6,"\"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.4}},"skip":{"\"Brave\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.6,"\"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.4}}},"skip":{"deeper":{"?0":{"\"Brave\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.6,"\"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.4}},"skip":{"\"Brave\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.6,"\"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":0.4}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\"":0.6,"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"":0.4}},"skip":{"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\"":0.6,"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"":0.4}}},"skip":{"deeper":{"?0":{"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\"":0.6,"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"":0.4}},"skip":{"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\"":0.6,"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"":0.4}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 GrokApp/1.3.71":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Google Chrome\";v=\"113\", \"Chromium\";v=\"113\", \"Not-A.Brand\";v=\"24\"":1}},"skip":{"\"Google Chrome\";v=\"113\", \"Chromium\";v=\"113\", \"Not-A.Brand\";v=\"24\"":1}}},"skip":{"deeper":{"?0":{"\"Google Chrome\";v=\"113\", \"Chromium\";v=\"113\", \"Not-A.Brand\";v=\"24\"":1}},"skip":{"\"Google Chrome\";v=\"113\", \"Chromium\";v=\"113\", \"Not-A.Brand\";v=\"24\"":1}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"":1}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Google Chrome\";v=\"144\"":0.8,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\"":0.1,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Brave\";v=\"144\"":0.1}},"skip":{"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Google Chrome\";v=\"144\"":0.8,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\"":0.1,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Brave\";v=\"144\"":0.1}}},"skip":{"deeper":{"?0":{"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Google Chrome\";v=\"144\"":0.8,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\"":0.1,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Brave\";v=\"144\"":0.1}},"skip":{"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Google Chrome\";v=\"144\"":0.8,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\"":0.1,"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Brave\";v=\"144\"":0.1}}},"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}},"skip":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}}},"skip":{"deeper":{"?0":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}},"skip":{"\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"108\", \"Google Chrome\";v=\"108\"":1}},"skip":{"\"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"108\", \"Google Chrome\";v=\"108\"":1}}},"skip":{"deeper":{"?0":{"\"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"108\", \"Google Chrome\";v=\"108\"":1}},"skip":{"\"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"108\", \"Google Chrome\";v=\"108\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"148\", \"Microsoft Edge\";v=\"148\", \"Not/A)Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"148\", \"Microsoft Edge\";v=\"148\", \"Not/A)Brand\";v=\"99\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"148\", \"Microsoft Edge\";v=\"148\", \"Not/A)Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"148\", \"Microsoft Edge\";v=\"148\", \"Not/A)Brand\";v=\"99\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.56 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"147\", \"Google Chrome\";v=\"147\"":1}},"skip":{"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"147\", \"Google Chrome\";v=\"147\"":1}}},"skip":{"deeper":{"?0":{"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"147\", \"Google Chrome\";v=\"147\"":1}},"skip":{"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"147\", \"Google Chrome\";v=\"147\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Microsoft Edge\";v=\"123\", \"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"123\"":1}},"skip":{"\"Microsoft Edge\";v=\"123\", \"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"123\"":1}}},"skip":{"deeper":{"?0":{"\"Microsoft Edge\";v=\"123\", \"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"123\"":1}},"skip":{"\"Microsoft Edge\";v=\"123\", \"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"123\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"136\", \"Google Chrome\";v=\"136\", \"Not.A/Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"136\", \"Google Chrome\";v=\"136\", \"Not.A/Brand\";v=\"99\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"136\", \"Google Chrome\";v=\"136\", \"Not.A/Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"136\", \"Google Chrome\";v=\"136\", \"Not.A/Brand\";v=\"99\"":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_15 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?1":{"\"Google Chrome\";v=\"119\", \"Chromium\";v=\"119\", \"Not?A_Brand\";v=\"24\"":1}},"skip":{"\"Google Chrome\";v=\"119\", \"Chromium\";v=\"119\", \"Not?A_Brand\";v=\"24\"":1}}},"skip":{"deeper":{"?1":{"\"Google Chrome\";v=\"119\", \"Chromium\";v=\"119\", \"Not?A_Brand\";v=\"24\"":1}},"skip":{"\"Google Chrome\";v=\"119\", \"Chromium\";v=\"119\", \"Not?A_Brand\";v=\"24\"":1}}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.138 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?1":{"\"Chromium\";v=\"142\", \"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"142\"":1}},"skip":{"\"Chromium\";v=\"142\", \"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"142\"":1}}},"skip":{"deeper":{"?1":{"\"Chromium\";v=\"142\", \"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"142\"":1}},"skip":{"\"Chromium\";v=\"142\", \"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"142\"":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Google Chrome\";v=\"149\", \"Chromium\";v=\"149\", \"Not)A;Brand\";v=\"24\"":1}},"skip":{"\"Google Chrome\";v=\"149\", \"Chromium\";v=\"149\", \"Not)A;Brand\";v=\"24\"":1}}},"skip":{"deeper":{"?0":{"\"Google Chrome\";v=\"149\", \"Chromium\";v=\"149\", \"Not)A;Brand\";v=\"24\"":1}},"skip":{"\"Google Chrome\";v=\"149\", \"Chromium\";v=\"149\", \"Not)A;Brand\";v=\"24\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Microsoft Edge\";v=\"138\"":1}},"skip":{"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Microsoft Edge\";v=\"138\"":1}}},"skip":{"deeper":{"?0":{"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Microsoft Edge\";v=\"138\"":1}},"skip":{"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Microsoft Edge\";v=\"138\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Android 13; Mobile; rv:136.0) Gecko/136.0 Firefox/136.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?1":{"\"Chromium\";v=\"146\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":1}},"skip":{"\"Chromium\";v=\"146\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":1}}},"skip":{"deeper":{"?1":{"\"Chromium\";v=\"146\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":1}},"skip":{"\"Chromium\";v=\"146\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"136\", \"Google Chrome\";v=\"136\", \"Not.A/Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"136\", \"Google Chrome\";v=\"136\", \"Not.A/Brand\";v=\"99\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"136\", \"Google Chrome\";v=\"136\", \"Not.A/Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"136\", \"Google Chrome\";v=\"136\", \"Not.A/Brand\";v=\"99\"":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?1":{"\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"":0.75,"\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\"":0.25}},"skip":{"\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"":0.75,"\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\"":0.25}}},"skip":{"deeper":{"?1":{"\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"":0.75,"\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\"":0.25}},"skip":{"\"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"":0.75,"\"Not:A-Brand\";v=\"99\", \"Brave\";v=\"145\", \"Chromium\";v=\"145\"":0.25}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.235 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.9273.1293 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/144.0.7559.95 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/23D127 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Brave\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"":0.5,"\"Google Chrome\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"":0.5}},"skip":{"\"Brave\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"":0.5,"\"Google Chrome\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"":0.5}}},"skip":{"deeper":{"?0":{"\"Brave\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"":0.5,"\"Google Chrome\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"":0.5}},"skip":{"\"Brave\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"":0.5,"\"Google Chrome\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"":0.5}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"142\", \"Microsoft Edge\";v=\"142\", \"Not_A Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"142\", \"Microsoft Edge\";v=\"142\", \"Not_A Brand\";v=\"99\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"142\", \"Microsoft Edge\";v=\"142\", \"Not_A Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"142\", \"Microsoft Edge\";v=\"142\", \"Not_A Brand\";v=\"99\"":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.3 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?1":{"\"Chromium\";v=\"143\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"143\"":1}},"skip":{"\"Chromium\";v=\"143\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"143\"":1}}},"skip":{"deeper":{"?1":{"\"Chromium\";v=\"143\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"143\"":1}},"skip":{"\"Chromium\";v=\"143\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"143\"":1}}},"Mozilla/5.0 (Linux; Android 12; X16DzOXpOQ; U; en) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.63 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not:A-Brand\";v=\"24\", \"Chromium\";v=\"134\"":0.5,"\"Chromium\";v=\"134\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"134\"":0.5}},"skip":{"\"Not:A-Brand\";v=\"24\", \"Chromium\";v=\"134\"":0.5,"\"Chromium\";v=\"134\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"134\"":0.5}}},"skip":{"deeper":{"?0":{"\"Not:A-Brand\";v=\"24\", \"Chromium\";v=\"134\"":0.5,"\"Chromium\";v=\"134\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"134\"":0.5}},"skip":{"\"Not:A-Brand\";v=\"24\", \"Chromium\";v=\"134\"":0.5,"\"Chromium\";v=\"134\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"134\"":0.5}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Linux; Android 15; SM-G991W Build/AP3A.240905.015.A2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?1":{"\"Android WebView\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}},"skip":{"\"Android WebView\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}}},"skip":{"deeper":{"?1":{"\"Android WebView\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}},"skip":{"\"Android WebView\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"122\", \"Not(A:Brand\";v=\"24\", \"Google Chrome\";v=\"122\"":1}},"skip":{"\"Chromium\";v=\"122\", \"Not(A:Brand\";v=\"24\", \"Google Chrome\";v=\"122\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"122\", \"Not(A:Brand\";v=\"24\", \"Google Chrome\";v=\"122\"":1}},"skip":{"\"Chromium\";v=\"122\", \"Not(A:Brand\";v=\"24\", \"Google Chrome\";v=\"122\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 CCleaner/146.0.34394.179":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"CCleaner Browser\";v=\"146\"":1}},"skip":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"CCleaner Browser\";v=\"146\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"CCleaner Browser\";v=\"146\"":1}},"skip":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"CCleaner Browser\";v=\"146\"":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.7 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"":1}},"skip":{"\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"":1}},"skip":{"\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"":0.75,"\"Chromium\";v=\"142\", \"Brave\";v=\"142\", \"Not_A Brand\";v=\"99\"":0.25}},"skip":{"\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"":0.75,"\"Chromium\";v=\"142\", \"Brave\";v=\"142\", \"Not_A Brand\";v=\"99\"":0.25}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"":0.75,"\"Chromium\";v=\"142\", \"Brave\";v=\"142\", \"Not_A Brand\";v=\"99\"":0.25}},"skip":{"\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"":0.75,"\"Chromium\";v=\"142\", \"Brave\";v=\"142\", \"Not_A Brand\";v=\"99\"":0.25}}},"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"122\", \"Not(A:Brand\";v=\"24\", \"Microsoft Edge\";v=\"122\"":1}},"skip":{"\"Chromium\";v=\"122\", \"Not(A:Brand\";v=\"24\", \"Microsoft Edge\";v=\"122\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"122\", \"Not(A:Brand\";v=\"24\", \"Microsoft Edge\";v=\"122\"":1}},"skip":{"\"Chromium\";v=\"122\", \"Not(A:Brand\";v=\"24\", \"Microsoft Edge\";v=\"122\"":1}}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?1":{"\"Chromium\";v=\"134\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"134\"":1}},"skip":{"\"Chromium\";v=\"134\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"134\"":1}}},"skip":{"deeper":{"?1":{"\"Chromium\";v=\"134\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"134\"":1}},"skip":{"\"Chromium\";v=\"134\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"134\"":1}}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?1":{"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Brave\";v=\"144\"":1}},"skip":{"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Brave\";v=\"144\"":1}}},"skip":{"deeper":{"?1":{"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Brave\";v=\"144\"":1}},"skip":{"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Brave\";v=\"144\"":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_14 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\".Not/A)Brand\";v=\"99\", \"Google Chrome\";v=\"103\", \"Chromium\";v=\"103\"":1}},"skip":{"\".Not/A)Brand\";v=\"99\", \"Google Chrome\";v=\"103\", \"Chromium\";v=\"103\"":1}}},"skip":{"deeper":{"?0":{"\".Not/A)Brand\";v=\"99\", \"Google Chrome\";v=\"103\", \"Chromium\";v=\"103\"":1}},"skip":{"\".Not/A)Brand\";v=\"99\", \"Google Chrome\";v=\"103\", \"Chromium\";v=\"103\"":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"":1}},"skip":{"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"":1}}},"skip":{"deeper":{"?0":{"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"":1}},"skip":{"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"":1}}},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":1}},"skip":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":1}},"skip":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"":1}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Brave\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"":1}},"skip":{"\"Brave\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"":1}}},"skip":{"deeper":{"?0":{"\"Brave\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"":1}},"skip":{"\"Brave\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"":1}}},"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"131\", \"Google Chrome\";v=\"131\", \"Not_A Brand\";v=\"24\"":1}},"skip":{"\"Chromium\";v=\"131\", \"Google Chrome\";v=\"131\", \"Not_A Brand\";v=\"24\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"131\", \"Google Chrome\";v=\"131\", \"Not_A Brand\";v=\"24\"":1}},"skip":{"\"Chromium\";v=\"131\", \"Google Chrome\";v=\"131\", \"Not_A Brand\";v=\"24\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"131\", \"Google Chrome\";v=\"131\", \"Not_A Brand\";v=\"24\"":1}},"skip":{"\"Chromium\";v=\"131\", \"Google Chrome\";v=\"131\", \"Not_A Brand\";v=\"24\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"131\", \"Google Chrome\";v=\"131\", \"Not_A Brand\";v=\"24\"":1}},"skip":{"\"Chromium\";v=\"131\", \"Google Chrome\";v=\"131\", \"Not_A Brand\";v=\"24\"":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1 Brave":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"132\", \"Google Chrome\";v=\"132\", \"Not_A Brand\";v=\"24\"":0.3333333333333333,"\"Not A(Brand\";v=\"8\", \"Chromium\";v=\"132\", \"Google Chrome\";v=\"132\"":0.6666666666666666}},"skip":{"\"Chromium\";v=\"132\", \"Google Chrome\";v=\"132\", \"Not_A Brand\";v=\"24\"":0.3333333333333333,"\"Not A(Brand\";v=\"8\", \"Chromium\";v=\"132\", \"Google Chrome\";v=\"132\"":0.6666666666666666}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"132\", \"Google Chrome\";v=\"132\", \"Not_A Brand\";v=\"24\"":0.3333333333333333,"\"Not A(Brand\";v=\"8\", \"Chromium\";v=\"132\", \"Google Chrome\";v=\"132\"":0.6666666666666666}},"skip":{"\"Chromium\";v=\"132\", \"Google Chrome\";v=\"132\", \"Not_A Brand\";v=\"24\"":0.3333333333333333,"\"Not A(Brand\";v=\"8\", \"Chromium\";v=\"132\", \"Google Chrome\";v=\"132\"":0.6666666666666666}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Linux; Android 15; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.73 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?1":{"\"Chromium\";v=\"130\", \"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"130\"":1}},"skip":{"\"Chromium\";v=\"130\", \"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"130\"":1}}},"skip":{"deeper":{"?1":{"\"Chromium\";v=\"130\", \"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"130\"":1}},"skip":{"\"Chromium\";v=\"130\", \"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"130\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not;A=Brand\";v=\"99\", \"Google Chrome\";v=\"139\", \"Chromium\";v=\"139\"":1}},"skip":{"\"Not;A=Brand\";v=\"99\", \"Google Chrome\";v=\"139\", \"Chromium\";v=\"139\"":1}}},"skip":{"deeper":{"?0":{"\"Not;A=Brand\";v=\"99\", \"Google Chrome\";v=\"139\", \"Chromium\";v=\"139\"":1}},"skip":{"\"Not;A=Brand\";v=\"99\", \"Google Chrome\";v=\"139\", \"Chromium\";v=\"139\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not(A:Brand\";v=\"99\", \"Google Chrome\";v=\"133\", \"Chromium\";v=\"133\"":1}},"skip":{"\"Not(A:Brand\";v=\"99\", \"Google Chrome\";v=\"133\", \"Chromium\";v=\"133\"":1}}},"skip":{"deeper":{"?0":{"\"Not(A:Brand\";v=\"99\", \"Google Chrome\";v=\"133\", \"Chromium\";v=\"133\"":1}},"skip":{"\"Not(A:Brand\";v=\"99\", \"Google Chrome\";v=\"133\", \"Chromium\";v=\"133\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"":1}},"skip":{"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"":1}},"skip":{"\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/23D8133 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?1":{"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":1}},"skip":{"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":1}}},"skip":{"deeper":{"?1":{"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":1}},"skip":{"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/23E261 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Google Chrome\";v=\"149\", \"Chromium\";v=\"149\", \"Not)A;Brand\";v=\"24\"":1}},"skip":{"\"Google Chrome\";v=\"149\", \"Chromium\";v=\"149\", \"Not)A;Brand\";v=\"24\"":1}}},"skip":{"deeper":{"?0":{"\"Google Chrome\";v=\"149\", \"Chromium\";v=\"149\", \"Not)A;Brand\";v=\"24\"":1}},"skip":{"\"Google Chrome\";v=\"149\", \"Chromium\";v=\"149\", \"Not)A;Brand\";v=\"24\"":1}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Google Chrome\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"":1}},"skip":{"\"Google Chrome\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"":1}}},"skip":{"deeper":{"?0":{"\"Google Chrome\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"":1}},"skip":{"\"Google Chrome\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"":1}}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?1":{"\"Google Chrome\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"":1}},"skip":{"\"Google Chrome\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"":1}}},"skip":{"deeper":{"?1":{"\"Google Chrome\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"":1}},"skip":{"\"Google Chrome\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"":1}}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?1":{"\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"":1}}},"skip":{"deeper":{"?1":{"\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"":1}}},"Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPad; CPU OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1 Brave":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"135\", \"Not-A.Brand\";v=\"8\"":1}},"skip":{"\"Chromium\";v=\"135\", \"Not-A.Brand\";v=\"8\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"135\", \"Not-A.Brand\";v=\"8\"":1}},"skip":{"\"Chromium\";v=\"135\", \"Not-A.Brand\";v=\"8\"":1}}},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"":1}},"skip":{"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"":1}}},"skip":{"deeper":{"?0":{"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"":1}},"skip":{"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"128\", \"Not;A=Brand\";v=\"24\", \"Google Chrome\";v=\"128\"":1}},"skip":{"\"Chromium\";v=\"128\", \"Not;A=Brand\";v=\"24\", \"Google Chrome\";v=\"128\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"128\", \"Not;A=Brand\";v=\"24\", \"Google Chrome\";v=\"128\"":1}},"skip":{"\"Chromium\";v=\"128\", \"Not;A=Brand\";v=\"24\", \"Google Chrome\";v=\"128\"":1}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Google Chrome\";v=\"135\", \"Not-A.Brand\";v=\"8\", \"Chromium\";v=\"135\"":1}},"skip":{"\"Google Chrome\";v=\"135\", \"Not-A.Brand\";v=\"8\", \"Chromium\";v=\"135\"":1}}},"skip":{"deeper":{"?0":{"\"Google Chrome\";v=\"135\", \"Not-A.Brand\";v=\"8\", \"Chromium\";v=\"135\"":1}},"skip":{"\"Google Chrome\";v=\"135\", \"Not-A.Brand\";v=\"8\", \"Chromium\";v=\"135\"":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.7 Mobile/22H340 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_8) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4.1 Mobile/22E252 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.3179.54":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"145\", \"Not:A-Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"145\", \"Not:A-Brand\";v=\"99\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"145\", \"Not:A-Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"145\", \"Not:A-Brand\";v=\"99\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"":1}},"skip":{"\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"":1}}},"skip":{"deeper":{"?0":{"\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"":1}},"skip":{"\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"":1}}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?1":{"\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"":1}},"skip":{"\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"":1}}},"skip":{"deeper":{"?1":{"\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"":1}},"skip":{"\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"":1}}},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Google Chrome\";v=\"144\"":1}},"skip":{"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Google Chrome\";v=\"144\"":1}}},"skip":{"deeper":{"?0":{"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Google Chrome\";v=\"144\"":1}},"skip":{"\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\", \"Google Chrome\";v=\"144\"":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not:A-Brand\";v=\"99\", \"Microsoft Edge\";v=\"145\", \"Chromium\";v=\"145\"":1}},"skip":{"\"Not:A-Brand\";v=\"99\", \"Microsoft Edge\";v=\"145\", \"Chromium\";v=\"145\"":1}}},"skip":{"deeper":{"?0":{"\"Not:A-Brand\";v=\"99\", \"Microsoft Edge\";v=\"145\", \"Chromium\";v=\"145\"":1}},"skip":{"\"Not:A-Brand\";v=\"99\", \"Microsoft Edge\";v=\"145\", \"Chromium\";v=\"145\"":1}}},"Mozilla/5.0 (Linux; Android 16; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}},"skip":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}}},"skip":{"deeper":{"?0":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}},"skip":{"\"Microsoft Edge\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"127\", \"Not)A;Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"127\", \"Not)A;Brand\";v=\"99\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"127\", \"Not)A;Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"127\", \"Not)A;Brand\";v=\"99\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Atom/26.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"106\", \"Atom\";v=\"26\", \"Not;A=Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"106\", \"Atom\";v=\"26\", \"Not;A=Brand\";v=\"99\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"106\", \"Atom\";v=\"26\", \"Not;A=Brand\";v=\"99\"":1}},"skip":{"\"Chromium\";v=\"106\", \"Atom\";v=\"26\", \"Not;A=Brand\";v=\"99\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"":1}},"skip":{"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"":1}}},"skip":{"deeper":{"?0":{"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"":1}},"skip":{"\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"":1}}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Microsoft Edge\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":1}},"skip":{"\"Microsoft Edge\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":1}}},"skip":{"deeper":{"?0":{"\"Microsoft Edge\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":1}},"skip":{"\"Microsoft Edge\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"":1}}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6788.76 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"132\", \"Google Chrome\";v=\"132\"":1}},"skip":{"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"132\", \"Google Chrome\";v=\"132\"":1}}},"skip":{"deeper":{"?0":{"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"132\", \"Google Chrome\";v=\"132\"":1}},"skip":{"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"132\", \"Google Chrome\";v=\"132\"":1}}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"skip":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"deeper":{"?0":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"":1}},"skip":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"":1}}},"skip":{"deeper":{"?0":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"":1}},"skip":{"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"":1}}}}}},{"name":"Connection","parentNames":["user-agent","User-Agent"],"possibleValues":["*MISSING_VALUE*","keep-alive"],"conditionalProbabilities":{"deeper":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"*MISSING_VALUE*":{"deeper":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"keep-alive":0.9655172413793104,"*MISSING_VALUE*":0.034482758620689655},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"keep-alive":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"keep-alive":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":{"keep-alive":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":{"keep-alive":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"keep-alive":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"keep-alive":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":{"keep-alive":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"keep-alive":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"keep-alive":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"keep-alive":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":{"keep-alive":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1}},"skip":{"keep-alive":0.9727272727272728,"*MISSING_VALUE*":0.02727272727272727}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.4 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.5 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1 Brave":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 Edg/147.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 Brave":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Android 16; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 EdgA/147.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.8909.1591 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4450.0 Safari/537.36 LarkUrl":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36 EdgA/143.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 GrokApp/1.3.71":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.56 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_15 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.138 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Android 13; Mobile; rv:136.0) Gecko/136.0 Firefox/136.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.235 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.9273.1293 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/144.0.7559.95 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/23D127 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.3 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 12; X16DzOXpOQ; U; en) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.63 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 15; SM-G991W Build/AP3A.240905.015.A2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 CCleaner/146.0.34394.179":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.7 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_14 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1 Brave":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 15; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.73 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/23D8133 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/23E261 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPad; CPU OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1 Brave":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.7 Mobile/22H340 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_8) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4.1 Mobile/22E252 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.3179.54":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 16; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Atom/26.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6788.76 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}}}},{"name":"te","parentNames":["user-agent","User-Agent"],"possibleValues":["*MISSING_VALUE*","trailers"],"conditionalProbabilities":{"deeper":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"*MISSING_VALUE*":{"deeper":{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"trailers":1}},"skip":{"trailers":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0":{"deeper":{"*MISSING_VALUE*":{"trailers":0.9473684210526315,"*MISSING_VALUE*":0.05263157894736842}},"skip":{"trailers":0.9473684210526315,"*MISSING_VALUE*":0.05263157894736842}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.4 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.5 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1 Brave":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0":{"deeper":{"*MISSING_VALUE*":{"trailers":1}},"skip":{"trailers":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 Edg/147.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 Brave":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0":{"deeper":{"*MISSING_VALUE*":{"trailers":1}},"skip":{"trailers":1}},"Mozilla/5.0 (Android 16; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"trailers":1}},"skip":{"trailers":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 EdgA/147.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.8909.1591 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4450.0 Safari/537.36 LarkUrl":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36 EdgA/143.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 GrokApp/1.3.71":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"trailers":1}},"skip":{"trailers":1}},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.56 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0":{"deeper":{"*MISSING_VALUE*":{"trailers":1}},"skip":{"trailers":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_15 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.138 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Android 13; Mobile; rv:136.0) Gecko/136.0 Firefox/136.0":{"deeper":{"*MISSING_VALUE*":{"trailers":1}},"skip":{"trailers":1}},"Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.235 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.9273.1293 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/144.0.7559.95 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/23D127 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.3 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 12; X16DzOXpOQ; U; en) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.63 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0":{"deeper":{"*MISSING_VALUE*":{"trailers":1}},"skip":{"trailers":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"trailers":1}},"skip":{"trailers":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 15; SM-G991W Build/AP3A.240905.015.A2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 CCleaner/146.0.34394.179":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.7 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0":{"deeper":{"*MISSING_VALUE*":{"trailers":1}},"skip":{"trailers":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_14 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":{"deeper":{"*MISSING_VALUE*":{"trailers":1}},"skip":{"trailers":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0":{"deeper":{"*MISSING_VALUE*":{"trailers":1}},"skip":{"trailers":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1 Brave":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 15; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.73 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/23D8133 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/23E261 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0":{"deeper":{"*MISSING_VALUE*":{"trailers":1}},"skip":{"trailers":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPad; CPU OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1 Brave":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0":{"deeper":{"*MISSING_VALUE*":{"trailers":1}},"skip":{"trailers":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.7 Mobile/22H340 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.2 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_8) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4.1 Mobile/22E252 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.3179.54":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 16; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Atom/26.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0":{"deeper":{"*MISSING_VALUE*":{"trailers":1}},"skip":{"trailers":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6788.76 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1}},"skip":{"*MISSING_VALUE*":1}}}}},{"name":"upgrade-insecure-requests","parentNames":["user-agent"],"possibleValues":["1","*MISSING_VALUE*"],"conditionalProbabilities":{"deeper":{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":{"*MISSING_VALUE*":1},"*MISSING_VALUE*":{"1":0.01818181818181818,"*MISSING_VALUE*":0.9818181818181818},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":{"1":0.0035971223021582736,"*MISSING_VALUE*":0.9964028776978417},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1":{"1":0.07692307692307693,"*MISSING_VALUE*":0.9230769230769231},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.4 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.5 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1 Brave":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0":{"1":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 Edg/147.0.0.0":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 Brave":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0":{"1":1},"Mozilla/5.0 (Android 16; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0":{"1":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36 EdgA/147.0.0.0":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.8909.1591 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4450.0 Safari/537.36 LarkUrl":{"1":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36 EdgA/143.0.0.0":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.7 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1 GrokApp/1.3.71":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36":{"1":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":{"1":1},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.56 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_15 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.2 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.138 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Android 13; Mobile; rv:136.0) Gecko/136.0 Firefox/136.0":{"1":1},"Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.235 Safari/537.36":{"1":1},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.9273.1293 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/144.0.7559.95 Mobile/15E148 Safari/604.1":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Mobile/23D127 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.3 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (Linux; Android 12; X16DzOXpOQ; U; en) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.63 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.8 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0":{"1":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 15; SM-G991W Build/AP3A.240905.015.A2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 CCleaner/146.0.34394.179":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.7 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0":{"1":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_14 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6.1 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0":{"1":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Mobile/15E148 Safari/604.1 Brave":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 15; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.73 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/23D8133 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/23E261 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.2 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPad; CPU OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1 Brave":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.7 Mobile/22H340 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.2 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_8) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4.1 Mobile/22E252 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.3179.54":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0":{"1":1},"Mozilla/5.0 (Linux; Android 16; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.111 Mobile Safari/537.36":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.8 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4.1 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Atom/26.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (iPhone; CPU iPhone OS 26_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Mobile/15E148 Safari/604.1":{"*MISSING_VALUE*":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6788.76 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15":{"1":1},"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"1":1}}}},{"name":"Upgrade-Insecure-Requests","parentNames":["User-Agent"],"possibleValues":["*MISSING_VALUE*","1"],"conditionalProbabilities":{"deeper":{"*MISSING_VALUE*":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"1":0.9827586206896551,"*MISSING_VALUE*":0.017241379310344827},"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36":{"1":0.9,"*MISSING_VALUE*":0.1},"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15":{"*MISSING_VALUE*":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36":{"1":1},"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; compatible; pageburst) Chrome/147.0.7727.116 Safari/537.36":{"1":1},"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36":{"1":1}}}}]} \ No newline at end of file diff --git a/browser/vendor/browserforge-1.2.4/input-network.json b/browser/vendor/browserforge-1.2.4/input-network.json new file mode 100644 index 000000000..1e997517f --- /dev/null +++ b/browser/vendor/browserforge-1.2.4/input-network.json @@ -0,0 +1 @@ +{"nodes":[{"name":"*DEVICE","parentNames":[],"possibleValues":["desktop","mobile"],"conditionalProbabilities":{"desktop":0.9038895152198422,"mobile":0.09611048478015784}},{"name":"*OPERATING_SYSTEM","parentNames":["*DEVICE"],"possibleValues":["macos","windows","ios","android","linux","*MISSING_VALUE*"],"conditionalProbabilities":{"deeper":{"desktop":{"macos":0.3178983473651388,"windows":0.6556750857499221,"linux":0.025335204240723417,"*MISSING_VALUE*":0.001091362644215778},"mobile":{"ios":0.7573313782991202,"android":0.24266862170087977}}}},{"name":"*BROWSER_HTTP","parentNames":["*OPERATING_SYSTEM","*DEVICE"],"possibleValues":["chrome/147.0.0.0|2","edge/147.0.0.0|2","safari/26.4|2","chrome/147.0.0.0|1","safari/26.3|2","chrome/146.0.0.0|2","firefox/150.0|2","safari/26.2|2","safari/18.5|2","safari/26.0.1|2","firefox/135.0|2","chrome/148.0.0.0|2","chrome/142.0.0.0|2","safari/18.3|2","chrome/144.0.0.0|2","safari/18.2|2","safari/26.3.1|2","safari/18.7|2","edge/129.0.0.0|2","safari/18.7.4|2","safari/18.1|2","safari/18.6|2","safari/18.7.5|2","chrome/145.0.0.0|2","chrome/127.0.0.0|2","safari/16.6|2","chrome/141.0.0.0|2","edge/147.0.0.0|1","chrome/121.0.0.0|2","chrome/143.0.0.0|2","chrome/128.0.0.0|2","safari/17.2.1|2","safari/26.5|2","chrome/116.0.0.0|2","chrome/138.0.0.0|2","safari/26.1|2","safari/17.6|2","firefox/151.0|2","chrome/130.0.0.0|2","chrome/115.0.0.0|2","edge/146.0.0.0|2","chrome/149.0.0.0|2","chrome/140.0.0.0|2","safari/16.4|2","safari/18.1.1|2","edge/144.0.0.0|2","edge/128.0.0.0|2","chrome/109.0.0.0|2","chrome/139.0.0.0|2","edge/143.0.0.0|2","edge/146.0.0.0|1","safari/26.4|1","safari/17.4|2","chrome/143.0.0.0|1","chrome/131.0.0.0|2","safari/18.4|2","safari/18.0.1|2","chrome/134.0.0.0|2","safari/16.5.2|2","safari/26.4.2|2","chrome/120.0.0.0|2","chrome/137.0.0.0|2","safari/26.0|2","safari/16.6.1|2","firefox/147.0|2","edge/145.0.0.0|2","chrome/135.0.0.0|2","safari/18.5|1","safari/17.5|2","chrome/45.0.8909.1591|2","chrome/145.0.0.0|1","chrome/91.0.4450.0|2","safari/17.7|2","chrome/125.0.0.0|2","chrome/126.0.0.0|2","chrome/113.0.0.0|2","chrome/101.0.4951.54|2","chrome/108.0.0.0|2","edge/148.0.0.0|2","chrome/142.0.0.0|1","chrome/147.0.7727.56|2","safari/17.4.1|2","edge/123.0.0.0|2","chrome/136.0.0.0|2","safari/16.6.2|2","chrome/119.0.0.0|2","chrome/142.0.7444.138|2","safari/18.3.1|2","edge/138.0.0.0|2","chrome/146.0.0.0|1","firefox/136.0|2","chrome/114.0.0.0|2","safari/16.5|2","chrome/138.0.7204.235|2","chrome/59.0.9273.1293|2","chrome/144.0.7559.95|2","safari/17.3|2","edge/142.0.0.0|2","safari/18.7.3|2","chrome/138.0.7204.63|2","firefox/149.0|2","safari/17.8|2","safari/15.6.8|2","safari/17.3.1|2","chrome/147.0.7727.111|2","chrome/124.0.0.0|2","chrome/122.0.0.0|2","safari/15.6.7|2","chrome/107.0.0.0|2","firefox/146.0|2","edge/122.0.0.0|2","safari/16.2|2","chrome/103.0.0.0|2","chrome/90.0.4430.212|2","safari/17.1.2|2","chrome/132.0.0.0|2","safari/18.0|2","chrome/130.0.6723.73|2","chrome/133.0.0.0|2","firefox/142.0|2","safari/17.0|2","safari/17.1|2","chrome/96.0.4664.110|1","safari/18.7.7|2","chrome/147.0.7727.116|1","safari/18.7.2|2","safari/15.6.1|2","chrome/116.0.0.0|1","safari/17.2|2","safari/18.4.1|2","edge/135.0.3179.54|2","safari/26.4.1|2","chrome/106.0.0.0|2","safari/16.1|2","chrome/91.0.4472.124|2","chrome/132.0.6788.76|2","safari/16.3|2"],"conditionalProbabilities":{"deeper":{"macos":{"deeper":{"desktop":{"chrome/147.0.0.0|2":0.5461010299166258,"safari/26.4|2":0.09097596861206474,"chrome/146.0.0.0|2":0.06866110838646396,"firefox/150.0|2":0.005640019617459539,"safari/26.2|2":0.026483570377636097,"safari/26.3|2":0.02746444335458558,"safari/26.0.1|2":0.006620892594409024,"safari/18.3|2":0.004659146640510054,"chrome/144.0.0.0|2":0.005885237861696911,"safari/18.2|2":0.0012260912211868563,"safari/26.3.1|2":0.06302108876900442,"safari/18.1|2":0.005394801373222168,"safari/18.6|2":0.032614026483570376,"chrome/145.0.0.0|2":0.02501226091221187,"chrome/148.0.0.0|2":0.0063756743501716525,"safari/16.6|2":0.0014713094654242277,"safari/18.5|2":0.009073075036782736,"chrome/143.0.0.0|2":0.006130456105934282,"chrome/128.0.0.0|2":0.0012260912211868563,"safari/17.2.1|2":0.0002452182442373713,"chrome/116.0.0.0|2":0.000980872976949485,"chrome/138.0.0.0|2":0.006866110838646395,"safari/17.6|2":0.005885237861696911,"firefox/151.0|2":0.0004904364884747426,"chrome/115.0.0.0|2":0.0002452182442373713,"safari/26.5|2":0.006620892594409024,"chrome/149.0.0.0|2":0.0004904364884747426,"safari/16.4|2":0.0004904364884747426,"safari/18.1.1|2":0.0012260912211868563,"safari/26.1|2":0.005394801373222168,"safari/26.4|1":0.0002452182442373713,"safari/17.4|2":0.0007356547327121138,"chrome/147.0.0.0|1":0.0024521824423737125,"safari/18.0.1|2":0.0002452182442373713,"edge/147.0.0.0|2":0.005149583128984796,"safari/16.5.2|2":0.0002452182442373713,"safari/26.0|2":0.004413928396272682,"safari/16.6.1|2":0.0004904364884747426,"firefox/147.0|2":0.0004904364884747426,"safari/18.5|1":0.0004904364884747426,"safari/17.5|2":0.0029426189308484553,"chrome/91.0.4450.0|2":0.0007356547327121138,"safari/17.4.1|2":0.0012260912211868563,"firefox/135.0|2":0.0002452182442373713,"safari/18.4|2":0.0007356547327121138,"chrome/114.0.0.0|2":0.0029426189308484553,"safari/18.3.1|2":0.000980872976949485,"chrome/138.0.7204.235|2":0.0002452182442373713,"chrome/141.0.0.0|2":0.000980872976949485,"safari/17.3|2":0.0007356547327121138,"safari/15.6.8|2":0.0002452182442373713,"safari/17.3.1|2":0.000980872976949485,"chrome/107.0.0.0|2":0.0004904364884747426,"chrome/142.0.0.0|2":0.000980872976949485,"safari/16.5|2":0.000980872976949485,"chrome/103.0.0.0|2":0.0002452182442373713,"safari/17.1.2|2":0.0002452182442373713,"chrome/131.0.0.0|2":0.0004904364884747426,"safari/18.0|2":0.000980872976949485,"chrome/139.0.0.0|2":0.0004904364884747426,"chrome/140.0.0.0|2":0.0007356547327121138,"safari/17.0|2":0.0002452182442373713,"safari/17.1|2":0.0004904364884747426,"chrome/135.0.0.0|2":0.0002452182442373713,"firefox/149.0|2":0.0004904364884747426,"safari/15.6.1|2":0.0002452182442373713,"safari/17.2|2":0.0002452182442373713,"chrome/130.0.0.0|2":0.0004904364884747426,"chrome/127.0.0.0|2":0.0004904364884747426,"safari/16.1|2":0.0002452182442373713,"safari/16.3|2":0.0002452182442373713}},"skip":{"chrome/147.0.0.0|2":0.5461010299166258,"safari/26.4|2":0.09097596861206474,"chrome/146.0.0.0|2":0.06866110838646396,"firefox/150.0|2":0.005640019617459539,"safari/26.2|2":0.026483570377636097,"safari/26.3|2":0.02746444335458558,"safari/26.0.1|2":0.006620892594409024,"safari/18.3|2":0.004659146640510054,"chrome/144.0.0.0|2":0.005885237861696911,"safari/18.2|2":0.0012260912211868563,"safari/26.3.1|2":0.06302108876900442,"safari/18.1|2":0.005394801373222168,"safari/18.6|2":0.032614026483570376,"chrome/145.0.0.0|2":0.02501226091221187,"chrome/148.0.0.0|2":0.0063756743501716525,"safari/16.6|2":0.0014713094654242277,"safari/18.5|2":0.009073075036782736,"chrome/143.0.0.0|2":0.006130456105934282,"chrome/128.0.0.0|2":0.0012260912211868563,"safari/17.2.1|2":0.0002452182442373713,"chrome/116.0.0.0|2":0.000980872976949485,"chrome/138.0.0.0|2":0.006866110838646395,"safari/17.6|2":0.005885237861696911,"firefox/151.0|2":0.0004904364884747426,"chrome/115.0.0.0|2":0.0002452182442373713,"safari/26.5|2":0.006620892594409024,"chrome/149.0.0.0|2":0.0004904364884747426,"safari/16.4|2":0.0004904364884747426,"safari/18.1.1|2":0.0012260912211868563,"safari/26.1|2":0.005394801373222168,"safari/26.4|1":0.0002452182442373713,"safari/17.4|2":0.0007356547327121138,"chrome/147.0.0.0|1":0.0024521824423737125,"safari/18.0.1|2":0.0002452182442373713,"edge/147.0.0.0|2":0.005149583128984796,"safari/16.5.2|2":0.0002452182442373713,"safari/26.0|2":0.004413928396272682,"safari/16.6.1|2":0.0004904364884747426,"firefox/147.0|2":0.0004904364884747426,"safari/18.5|1":0.0004904364884747426,"safari/17.5|2":0.0029426189308484553,"chrome/91.0.4450.0|2":0.0007356547327121138,"safari/17.4.1|2":0.0012260912211868563,"firefox/135.0|2":0.0002452182442373713,"safari/18.4|2":0.0007356547327121138,"chrome/114.0.0.0|2":0.0029426189308484553,"safari/18.3.1|2":0.000980872976949485,"chrome/138.0.7204.235|2":0.0002452182442373713,"chrome/141.0.0.0|2":0.000980872976949485,"safari/17.3|2":0.0007356547327121138,"safari/15.6.8|2":0.0002452182442373713,"safari/17.3.1|2":0.000980872976949485,"chrome/107.0.0.0|2":0.0004904364884747426,"chrome/142.0.0.0|2":0.000980872976949485,"safari/16.5|2":0.000980872976949485,"chrome/103.0.0.0|2":0.0002452182442373713,"safari/17.1.2|2":0.0002452182442373713,"chrome/131.0.0.0|2":0.0004904364884747426,"safari/18.0|2":0.000980872976949485,"chrome/139.0.0.0|2":0.0004904364884747426,"chrome/140.0.0.0|2":0.0007356547327121138,"safari/17.0|2":0.0002452182442373713,"safari/17.1|2":0.0004904364884747426,"chrome/135.0.0.0|2":0.0002452182442373713,"firefox/149.0|2":0.0004904364884747426,"safari/15.6.1|2":0.0002452182442373713,"safari/17.2|2":0.0002452182442373713,"chrome/130.0.0.0|2":0.0004904364884747426,"chrome/127.0.0.0|2":0.0004904364884747426,"safari/16.1|2":0.0002452182442373713,"safari/16.3|2":0.0002452182442373713}},"windows":{"deeper":{"desktop":{"chrome/147.0.0.0|2":0.7963381286410652,"edge/147.0.0.0|2":0.10807276185946975,"chrome/147.0.0.0|1":0.006895731779812151,"firefox/135.0|2":0.0022589466175246702,"chrome/148.0.0.0|2":0.008322434906669837,"chrome/142.0.0.0|2":0.012245868505528475,"edge/129.0.0.0|2":0.0003566757817144216,"chrome/146.0.0.0|2":0.013315895850671739,"chrome/127.0.0.0|2":0.00011889192723814053,"chrome/145.0.0.0|2":0.012721436214481036,"chrome/141.0.0.0|2":0.001783378908572108,"edge/147.0.0.0|1":0.0021400546902865295,"chrome/121.0.0.0|2":0.0004755677089525621,"edge/146.0.0.0|2":0.0029722981809535134,"chrome/138.0.0.0|2":0.0019022708358102484,"chrome/140.0.0.0|2":0.0009511354179051242,"edge/144.0.0.0|2":0.00023778385447628106,"edge/128.0.0.0|2":0.00011889192723814053,"chrome/109.0.0.0|2":0.0029722981809535134,"chrome/139.0.0.0|2":0.0013078111996195458,"edge/143.0.0.0|2":0.0009511354179051242,"edge/146.0.0.0|1":0.0004755677089525621,"chrome/143.0.0.0|1":0.00011889192723814053,"chrome/131.0.0.0|2":0.0004755677089525621,"chrome/134.0.0.0|2":0.0007133515634288432,"chrome/130.0.0.0|2":0.0004755677089525621,"chrome/120.0.0.0|2":0.0003566757817144216,"chrome/137.0.0.0|2":0.0009511354179051242,"edge/145.0.0.0|2":0.0004755677089525621,"chrome/135.0.0.0|2":0.0010700273451432648,"chrome/143.0.0.0|2":0.0027345143264772324,"chrome/145.0.0.0|1":0.0003566757817144216,"chrome/144.0.0.0|2":0.002021162763048389,"chrome/125.0.0.0|2":0.0011889192723814053,"chrome/126.0.0.0|2":0.0005944596361907026,"chrome/113.0.0.0|2":0.00011889192723814053,"chrome/108.0.0.0|2":0.00023778385447628106,"edge/148.0.0.0|2":0.0021400546902865295,"chrome/142.0.0.0|1":0.00011889192723814053,"chrome/147.0.7727.56|2":0.0005944596361907026,"edge/123.0.0.0|2":0.0003566757817144216,"chrome/136.0.0.0|2":0.0005944596361907026,"chrome/149.0.0.0|2":0.0008322434906669837,"edge/138.0.0.0|2":0.00011889192723814053,"chrome/146.0.0.0|1":0.0007133515634288432,"edge/142.0.0.0|2":0.00023778385447628106,"firefox/150.0|2":0.0014267031268576863,"chrome/124.0.0.0|2":0.0003566757817144216,"chrome/122.0.0.0|2":0.0005944596361907026,"edge/122.0.0.0|2":0.00011889192723814053,"firefox/149.0|2":0.00023778385447628106,"chrome/132.0.0.0|2":0.0003566757817144216,"chrome/133.0.0.0|2":0.0005944596361907026,"chrome/96.0.4664.110|1":0.00011889192723814053,"chrome/128.0.0.0|2":0.0003566757817144216,"chrome/116.0.0.0|1":0.00011889192723814053,"edge/135.0.3179.54|2":0.00011889192723814053,"chrome/106.0.0.0|2":0.00023778385447628106,"firefox/147.0|2":0.00023778385447628106,"chrome/91.0.4472.124|2":0.0004755677089525621,"chrome/132.0.6788.76|2":0.00011889192723814053}},"skip":{"chrome/147.0.0.0|2":0.7963381286410652,"edge/147.0.0.0|2":0.10807276185946975,"chrome/147.0.0.0|1":0.006895731779812151,"firefox/135.0|2":0.0022589466175246702,"chrome/148.0.0.0|2":0.008322434906669837,"chrome/142.0.0.0|2":0.012245868505528475,"edge/129.0.0.0|2":0.0003566757817144216,"chrome/146.0.0.0|2":0.013315895850671739,"chrome/127.0.0.0|2":0.00011889192723814053,"chrome/145.0.0.0|2":0.012721436214481036,"chrome/141.0.0.0|2":0.001783378908572108,"edge/147.0.0.0|1":0.0021400546902865295,"chrome/121.0.0.0|2":0.0004755677089525621,"edge/146.0.0.0|2":0.0029722981809535134,"chrome/138.0.0.0|2":0.0019022708358102484,"chrome/140.0.0.0|2":0.0009511354179051242,"edge/144.0.0.0|2":0.00023778385447628106,"edge/128.0.0.0|2":0.00011889192723814053,"chrome/109.0.0.0|2":0.0029722981809535134,"chrome/139.0.0.0|2":0.0013078111996195458,"edge/143.0.0.0|2":0.0009511354179051242,"edge/146.0.0.0|1":0.0004755677089525621,"chrome/143.0.0.0|1":0.00011889192723814053,"chrome/131.0.0.0|2":0.0004755677089525621,"chrome/134.0.0.0|2":0.0007133515634288432,"chrome/130.0.0.0|2":0.0004755677089525621,"chrome/120.0.0.0|2":0.0003566757817144216,"chrome/137.0.0.0|2":0.0009511354179051242,"edge/145.0.0.0|2":0.0004755677089525621,"chrome/135.0.0.0|2":0.0010700273451432648,"chrome/143.0.0.0|2":0.0027345143264772324,"chrome/145.0.0.0|1":0.0003566757817144216,"chrome/144.0.0.0|2":0.002021162763048389,"chrome/125.0.0.0|2":0.0011889192723814053,"chrome/126.0.0.0|2":0.0005944596361907026,"chrome/113.0.0.0|2":0.00011889192723814053,"chrome/108.0.0.0|2":0.00023778385447628106,"edge/148.0.0.0|2":0.0021400546902865295,"chrome/142.0.0.0|1":0.00011889192723814053,"chrome/147.0.7727.56|2":0.0005944596361907026,"edge/123.0.0.0|2":0.0003566757817144216,"chrome/136.0.0.0|2":0.0005944596361907026,"chrome/149.0.0.0|2":0.0008322434906669837,"edge/138.0.0.0|2":0.00011889192723814053,"chrome/146.0.0.0|1":0.0007133515634288432,"edge/142.0.0.0|2":0.00023778385447628106,"firefox/150.0|2":0.0014267031268576863,"chrome/124.0.0.0|2":0.0003566757817144216,"chrome/122.0.0.0|2":0.0005944596361907026,"edge/122.0.0.0|2":0.00011889192723814053,"firefox/149.0|2":0.00023778385447628106,"chrome/132.0.0.0|2":0.0003566757817144216,"chrome/133.0.0.0|2":0.0005944596361907026,"chrome/96.0.4664.110|1":0.00011889192723814053,"chrome/128.0.0.0|2":0.0003566757817144216,"chrome/116.0.0.0|1":0.00011889192723814053,"edge/135.0.3179.54|2":0.00011889192723814053,"chrome/106.0.0.0|2":0.00023778385447628106,"firefox/147.0|2":0.00023778385447628106,"chrome/91.0.4472.124|2":0.0004755677089525621,"chrome/132.0.6788.76|2":0.00011889192723814053}},"ios":{"deeper":{"mobile":{"safari/26.3|2":0.4181994191674734,"safari/26.4|2":0.271055179090029,"safari/18.5|2":0.012584704743465635,"safari/18.7|2":0.003872216844143272,"safari/18.7.4|2":0.003872216844143272,"safari/26.2|2":0.06873184898354308,"safari/18.7.5|2":0.04259438528557599,"safari/18.3|2":0.005808325266214908,"safari/26.3.1|2":0.006776379477250726,"safari/26.5|2":0.03388189738625363,"safari/26.1|2":0.026137463697967087,"safari/18.6|2":0.02516940948693127,"safari/17.6|2":0.00968054211035818,"safari/18.2|2":0.003872216844143272,"safari/18.4|2":0.002904162633107454,"safari/26.4.2|2":0.007744433688286544,"chrome/45.0.8909.1591|2":0.000968054211035818,"safari/17.7|2":0.001936108422071636,"safari/16.6.2|2":0.00484027105517909,"safari/18.3.1|2":0.003872216844143272,"safari/16.5|2":0.001936108422071636,"chrome/144.0.7559.95|2":0.001936108422071636,"safari/18.7.3|2":0.002904162633107454,"safari/26.0|2":0.00484027105517909,"safari/17.8|2":0.000968054211035818,"safari/15.6.7|2":0.00484027105517909,"safari/16.6.1|2":0.000968054211035818,"safari/16.2|2":0.000968054211035818,"safari/17.4.1|2":0.000968054211035818,"safari/26.0.1|2":0.007744433688286544,"safari/18.7.7|2":0.000968054211035818,"safari/18.7.2|2":0.001936108422071636,"safari/18.4.1|2":0.000968054211035818,"safari/18.0|2":0.000968054211035818,"safari/15.6.8|2":0.010648596321393998,"safari/17.5|2":0.000968054211035818,"safari/26.4.1|2":0.000968054211035818}},"skip":{"safari/26.3|2":0.4181994191674734,"safari/26.4|2":0.271055179090029,"safari/18.5|2":0.012584704743465635,"safari/18.7|2":0.003872216844143272,"safari/18.7.4|2":0.003872216844143272,"safari/26.2|2":0.06873184898354308,"safari/18.7.5|2":0.04259438528557599,"safari/18.3|2":0.005808325266214908,"safari/26.3.1|2":0.006776379477250726,"safari/26.5|2":0.03388189738625363,"safari/26.1|2":0.026137463697967087,"safari/18.6|2":0.02516940948693127,"safari/17.6|2":0.00968054211035818,"safari/18.2|2":0.003872216844143272,"safari/18.4|2":0.002904162633107454,"safari/26.4.2|2":0.007744433688286544,"chrome/45.0.8909.1591|2":0.000968054211035818,"safari/17.7|2":0.001936108422071636,"safari/16.6.2|2":0.00484027105517909,"safari/18.3.1|2":0.003872216844143272,"safari/16.5|2":0.001936108422071636,"chrome/144.0.7559.95|2":0.001936108422071636,"safari/18.7.3|2":0.002904162633107454,"safari/26.0|2":0.00484027105517909,"safari/17.8|2":0.000968054211035818,"safari/15.6.7|2":0.00484027105517909,"safari/16.6.1|2":0.000968054211035818,"safari/16.2|2":0.000968054211035818,"safari/17.4.1|2":0.000968054211035818,"safari/26.0.1|2":0.007744433688286544,"safari/18.7.7|2":0.000968054211035818,"safari/18.7.2|2":0.001936108422071636,"safari/18.4.1|2":0.000968054211035818,"safari/18.0|2":0.000968054211035818,"safari/15.6.8|2":0.010648596321393998,"safari/17.5|2":0.000968054211035818,"safari/26.4.1|2":0.000968054211035818}},"android":{"deeper":{"mobile":{"chrome/147.0.0.0|2":0.7673716012084593,"chrome/138.0.0.0|2":0.04229607250755287,"edge/147.0.0.0|2":0.01812688821752266,"firefox/150.0|2":0.03323262839879154,"edge/143.0.0.0|2":0.006042296072507553,"chrome/119.0.0.0|2":0.01812688821752266,"chrome/142.0.7444.138|2":0.0030211480362537764,"firefox/136.0|2":0.0030211480362537764,"chrome/146.0.0.0|2":0.00906344410876133,"chrome/145.0.0.0|2":0.012084592145015106,"chrome/59.0.9273.1293|2":0.0030211480362537764,"chrome/143.0.0.0|2":0.012084592145015106,"chrome/138.0.7204.63|2":0.0030211480362537764,"chrome/147.0.7727.111|2":0.01812688821752266,"chrome/134.0.0.0|2":0.006042296072507553,"chrome/144.0.0.0|2":0.02416918429003021,"chrome/130.0.6723.73|2":0.0030211480362537764,"chrome/137.0.0.0|2":0.006042296072507553,"chrome/148.0.0.0|2":0.00906344410876133,"chrome/120.0.0.0|2":0.0030211480362537764}},"skip":{"chrome/147.0.0.0|2":0.7673716012084593,"chrome/138.0.0.0|2":0.04229607250755287,"edge/147.0.0.0|2":0.01812688821752266,"firefox/150.0|2":0.03323262839879154,"edge/143.0.0.0|2":0.006042296072507553,"chrome/119.0.0.0|2":0.01812688821752266,"chrome/142.0.7444.138|2":0.0030211480362537764,"firefox/136.0|2":0.0030211480362537764,"chrome/146.0.0.0|2":0.00906344410876133,"chrome/145.0.0.0|2":0.012084592145015106,"chrome/59.0.9273.1293|2":0.0030211480362537764,"chrome/143.0.0.0|2":0.012084592145015106,"chrome/138.0.7204.63|2":0.0030211480362537764,"chrome/147.0.7727.111|2":0.01812688821752266,"chrome/134.0.0.0|2":0.006042296072507553,"chrome/144.0.0.0|2":0.02416918429003021,"chrome/130.0.6723.73|2":0.0030211480362537764,"chrome/137.0.0.0|2":0.006042296072507553,"chrome/148.0.0.0|2":0.00906344410876133,"chrome/120.0.0.0|2":0.0030211480362537764}},"linux":{"deeper":{"desktop":{"chrome/147.0.0.0|2":0.5538461538461539,"chrome/146.0.0.0|2":0.13230769230769232,"chrome/148.0.0.0|2":0.006153846153846154,"chrome/145.0.0.0|2":0.06769230769230769,"chrome/130.0.0.0|2":0.006153846153846154,"chrome/140.0.0.0|2":0.009230769230769232,"chrome/143.0.0.0|2":0.015384615384615385,"chrome/142.0.0.0|2":0.006153846153846154,"chrome/101.0.4951.54|2":0.03076923076923077,"chrome/144.0.0.0|2":0.03076923076923077,"firefox/150.0|2":0.036923076923076927,"chrome/136.0.0.0|2":0.003076923076923077,"firefox/149.0|2":0.009230769230769232,"chrome/134.0.0.0|2":0.006153846153846154,"firefox/146.0|2":0.006153846153846154,"chrome/90.0.4430.212|2":0.003076923076923077,"chrome/126.0.0.0|2":0.006153846153846154,"chrome/141.0.0.0|2":0.003076923076923077,"chrome/131.0.0.0|2":0.015384615384615385,"chrome/149.0.0.0|2":0.006153846153846154,"chrome/137.0.0.0|2":0.006153846153846154,"firefox/142.0|2":0.006153846153846154,"chrome/135.0.0.0|2":0.003076923076923077,"chrome/147.0.7727.116|1":0.012307692307692308,"edge/145.0.0.0|2":0.003076923076923077,"edge/147.0.0.0|2":0.003076923076923077,"chrome/138.0.0.0|2":0.009230769230769232,"edge/143.0.0.0|2":0.003076923076923077}},"skip":{"chrome/147.0.0.0|2":0.5538461538461539,"chrome/146.0.0.0|2":0.13230769230769232,"chrome/148.0.0.0|2":0.006153846153846154,"chrome/145.0.0.0|2":0.06769230769230769,"chrome/130.0.0.0|2":0.006153846153846154,"chrome/140.0.0.0|2":0.009230769230769232,"chrome/143.0.0.0|2":0.015384615384615385,"chrome/142.0.0.0|2":0.006153846153846154,"chrome/101.0.4951.54|2":0.03076923076923077,"chrome/144.0.0.0|2":0.03076923076923077,"firefox/150.0|2":0.036923076923076927,"chrome/136.0.0.0|2":0.003076923076923077,"firefox/149.0|2":0.009230769230769232,"chrome/134.0.0.0|2":0.006153846153846154,"firefox/146.0|2":0.006153846153846154,"chrome/90.0.4430.212|2":0.003076923076923077,"chrome/126.0.0.0|2":0.006153846153846154,"chrome/141.0.0.0|2":0.003076923076923077,"chrome/131.0.0.0|2":0.015384615384615385,"chrome/149.0.0.0|2":0.006153846153846154,"chrome/137.0.0.0|2":0.006153846153846154,"firefox/142.0|2":0.006153846153846154,"chrome/135.0.0.0|2":0.003076923076923077,"chrome/147.0.7727.116|1":0.012307692307692308,"edge/145.0.0.0|2":0.003076923076923077,"edge/147.0.0.0|2":0.003076923076923077,"chrome/138.0.0.0|2":0.009230769230769232,"edge/143.0.0.0|2":0.003076923076923077}},"*MISSING_VALUE*":{"deeper":{"desktop":{"chrome/147.0.0.0|1":0.07142857142857142,"chrome/147.0.0.0|2":0.5714285714285714,"chrome/146.0.0.0|2":0.14285714285714285,"chrome/126.0.0.0|2":0.14285714285714285,"chrome/144.0.0.0|2":0.07142857142857142}},"skip":{"chrome/147.0.0.0|1":0.07142857142857142,"chrome/147.0.0.0|2":0.5714285714285714,"chrome/146.0.0.0|2":0.14285714285714285,"chrome/126.0.0.0|2":0.14285714285714285,"chrome/144.0.0.0|2":0.07142857142857142}}}}},{"name":"*HTTP_VERSION","parentNames":["*BROWSER_HTTP"],"possibleValues":["_2.0_","_1.1_"],"conditionalProbabilities":{"deeper":{"chrome/147.0.0.0|2":{"_2.0_":1},"edge/147.0.0.0|2":{"_2.0_":1},"safari/26.4|2":{"_2.0_":1},"chrome/147.0.0.0|1":{"_1.1_":1},"safari/26.3|2":{"_2.0_":1},"chrome/146.0.0.0|2":{"_2.0_":1},"firefox/150.0|2":{"_2.0_":1},"safari/26.2|2":{"_2.0_":1},"safari/18.5|2":{"_2.0_":1},"safari/26.0.1|2":{"_2.0_":1},"firefox/135.0|2":{"_2.0_":1},"chrome/148.0.0.0|2":{"_2.0_":1},"chrome/142.0.0.0|2":{"_2.0_":1},"safari/18.3|2":{"_2.0_":1},"chrome/144.0.0.0|2":{"_2.0_":1},"safari/18.2|2":{"_2.0_":1},"safari/26.3.1|2":{"_2.0_":1},"safari/18.7|2":{"_2.0_":1},"edge/129.0.0.0|2":{"_2.0_":1},"safari/18.7.4|2":{"_2.0_":1},"safari/18.1|2":{"_2.0_":1},"safari/18.6|2":{"_2.0_":1},"safari/18.7.5|2":{"_2.0_":1},"chrome/145.0.0.0|2":{"_2.0_":1},"chrome/127.0.0.0|2":{"_2.0_":1},"safari/16.6|2":{"_2.0_":1},"chrome/141.0.0.0|2":{"_2.0_":1},"edge/147.0.0.0|1":{"_1.1_":1},"chrome/121.0.0.0|2":{"_2.0_":1},"chrome/143.0.0.0|2":{"_2.0_":1},"chrome/128.0.0.0|2":{"_2.0_":1},"safari/17.2.1|2":{"_2.0_":1},"safari/26.5|2":{"_2.0_":1},"chrome/116.0.0.0|2":{"_2.0_":1},"chrome/138.0.0.0|2":{"_2.0_":1},"safari/26.1|2":{"_2.0_":1},"safari/17.6|2":{"_2.0_":1},"firefox/151.0|2":{"_2.0_":1},"chrome/130.0.0.0|2":{"_2.0_":1},"chrome/115.0.0.0|2":{"_2.0_":1},"edge/146.0.0.0|2":{"_2.0_":1},"chrome/149.0.0.0|2":{"_2.0_":1},"chrome/140.0.0.0|2":{"_2.0_":1},"safari/16.4|2":{"_2.0_":1},"safari/18.1.1|2":{"_2.0_":1},"edge/144.0.0.0|2":{"_2.0_":1},"edge/128.0.0.0|2":{"_2.0_":1},"chrome/109.0.0.0|2":{"_2.0_":1},"chrome/139.0.0.0|2":{"_2.0_":1},"edge/143.0.0.0|2":{"_2.0_":1},"edge/146.0.0.0|1":{"_1.1_":1},"safari/26.4|1":{"_1.1_":1},"safari/17.4|2":{"_2.0_":1},"chrome/143.0.0.0|1":{"_1.1_":1},"chrome/131.0.0.0|2":{"_2.0_":1},"safari/18.4|2":{"_2.0_":1},"safari/18.0.1|2":{"_2.0_":1},"chrome/134.0.0.0|2":{"_2.0_":1},"safari/16.5.2|2":{"_2.0_":1},"safari/26.4.2|2":{"_2.0_":1},"chrome/120.0.0.0|2":{"_2.0_":1},"chrome/137.0.0.0|2":{"_2.0_":1},"safari/26.0|2":{"_2.0_":1},"safari/16.6.1|2":{"_2.0_":1},"firefox/147.0|2":{"_2.0_":1},"edge/145.0.0.0|2":{"_2.0_":1},"chrome/135.0.0.0|2":{"_2.0_":1},"safari/18.5|1":{"_1.1_":1},"safari/17.5|2":{"_2.0_":1},"chrome/45.0.8909.1591|2":{"_2.0_":1},"chrome/145.0.0.0|1":{"_1.1_":1},"chrome/91.0.4450.0|2":{"_2.0_":1},"safari/17.7|2":{"_2.0_":1},"chrome/125.0.0.0|2":{"_2.0_":1},"chrome/126.0.0.0|2":{"_2.0_":1},"chrome/113.0.0.0|2":{"_2.0_":1},"chrome/101.0.4951.54|2":{"_2.0_":1},"chrome/108.0.0.0|2":{"_2.0_":1},"edge/148.0.0.0|2":{"_2.0_":1},"chrome/142.0.0.0|1":{"_1.1_":1},"chrome/147.0.7727.56|2":{"_2.0_":1},"safari/17.4.1|2":{"_2.0_":1},"edge/123.0.0.0|2":{"_2.0_":1},"chrome/136.0.0.0|2":{"_2.0_":1},"safari/16.6.2|2":{"_2.0_":1},"chrome/119.0.0.0|2":{"_2.0_":1},"chrome/142.0.7444.138|2":{"_2.0_":1},"safari/18.3.1|2":{"_2.0_":1},"edge/138.0.0.0|2":{"_2.0_":1},"chrome/146.0.0.0|1":{"_1.1_":1},"firefox/136.0|2":{"_2.0_":1},"chrome/114.0.0.0|2":{"_2.0_":1},"safari/16.5|2":{"_2.0_":1},"chrome/138.0.7204.235|2":{"_2.0_":1},"chrome/59.0.9273.1293|2":{"_2.0_":1},"chrome/144.0.7559.95|2":{"_2.0_":1},"safari/17.3|2":{"_2.0_":1},"edge/142.0.0.0|2":{"_2.0_":1},"safari/18.7.3|2":{"_2.0_":1},"chrome/138.0.7204.63|2":{"_2.0_":1},"firefox/149.0|2":{"_2.0_":1},"safari/17.8|2":{"_2.0_":1},"safari/15.6.8|2":{"_2.0_":1},"safari/17.3.1|2":{"_2.0_":1},"chrome/147.0.7727.111|2":{"_2.0_":1},"chrome/124.0.0.0|2":{"_2.0_":1},"chrome/122.0.0.0|2":{"_2.0_":1},"safari/15.6.7|2":{"_2.0_":1},"chrome/107.0.0.0|2":{"_2.0_":1},"firefox/146.0|2":{"_2.0_":1},"edge/122.0.0.0|2":{"_2.0_":1},"safari/16.2|2":{"_2.0_":1},"chrome/103.0.0.0|2":{"_2.0_":1},"chrome/90.0.4430.212|2":{"_2.0_":1},"safari/17.1.2|2":{"_2.0_":1},"chrome/132.0.0.0|2":{"_2.0_":1},"safari/18.0|2":{"_2.0_":1},"chrome/130.0.6723.73|2":{"_2.0_":1},"chrome/133.0.0.0|2":{"_2.0_":1},"firefox/142.0|2":{"_2.0_":1},"safari/17.0|2":{"_2.0_":1},"safari/17.1|2":{"_2.0_":1},"chrome/96.0.4664.110|1":{"_1.1_":1},"safari/18.7.7|2":{"_2.0_":1},"chrome/147.0.7727.116|1":{"_1.1_":1},"safari/18.7.2|2":{"_2.0_":1},"safari/15.6.1|2":{"_2.0_":1},"chrome/116.0.0.0|1":{"_1.1_":1},"safari/17.2|2":{"_2.0_":1},"safari/18.4.1|2":{"_2.0_":1},"edge/135.0.3179.54|2":{"_2.0_":1},"safari/26.4.1|2":{"_2.0_":1},"chrome/106.0.0.0|2":{"_2.0_":1},"safari/16.1|2":{"_2.0_":1},"chrome/91.0.4472.124|2":{"_2.0_":1},"chrome/132.0.6788.76|2":{"_2.0_":1},"safari/16.3|2":{"_2.0_":1}}}},{"name":"*BROWSER","parentNames":["*BROWSER_HTTP"],"possibleValues":["chrome/147.0.0.0","edge/147.0.0.0","safari/26.4","safari/26.3","chrome/146.0.0.0","firefox/150.0","safari/26.2","safari/18.5","safari/26.0.1","firefox/135.0","chrome/148.0.0.0","chrome/142.0.0.0","safari/18.3","chrome/144.0.0.0","safari/18.2","safari/26.3.1","safari/18.7","edge/129.0.0.0","safari/18.7.4","safari/18.1","safari/18.6","safari/18.7.5","chrome/145.0.0.0","chrome/127.0.0.0","safari/16.6","chrome/141.0.0.0","chrome/121.0.0.0","chrome/143.0.0.0","chrome/128.0.0.0","safari/17.2.1","safari/26.5","chrome/116.0.0.0","chrome/138.0.0.0","safari/26.1","safari/17.6","firefox/151.0","chrome/130.0.0.0","chrome/115.0.0.0","edge/146.0.0.0","chrome/149.0.0.0","chrome/140.0.0.0","safari/16.4","safari/18.1.1","edge/144.0.0.0","edge/128.0.0.0","chrome/109.0.0.0","chrome/139.0.0.0","edge/143.0.0.0","safari/17.4","chrome/131.0.0.0","safari/18.4","safari/18.0.1","chrome/134.0.0.0","safari/16.5.2","safari/26.4.2","chrome/120.0.0.0","chrome/137.0.0.0","safari/26.0","safari/16.6.1","firefox/147.0","edge/145.0.0.0","chrome/135.0.0.0","safari/17.5","chrome/45.0.8909.1591","chrome/91.0.4450.0","safari/17.7","chrome/125.0.0.0","chrome/126.0.0.0","chrome/113.0.0.0","chrome/101.0.4951.54","chrome/108.0.0.0","edge/148.0.0.0","chrome/147.0.7727.56","safari/17.4.1","edge/123.0.0.0","chrome/136.0.0.0","safari/16.6.2","chrome/119.0.0.0","chrome/142.0.7444.138","safari/18.3.1","edge/138.0.0.0","firefox/136.0","chrome/114.0.0.0","safari/16.5","chrome/138.0.7204.235","chrome/59.0.9273.1293","chrome/144.0.7559.95","safari/17.3","edge/142.0.0.0","safari/18.7.3","chrome/138.0.7204.63","firefox/149.0","safari/17.8","safari/15.6.8","safari/17.3.1","chrome/147.0.7727.111","chrome/124.0.0.0","chrome/122.0.0.0","safari/15.6.7","chrome/107.0.0.0","firefox/146.0","edge/122.0.0.0","safari/16.2","chrome/103.0.0.0","chrome/90.0.4430.212","safari/17.1.2","chrome/132.0.0.0","safari/18.0","chrome/130.0.6723.73","chrome/133.0.0.0","firefox/142.0","safari/17.0","safari/17.1","chrome/96.0.4664.110","safari/18.7.7","chrome/147.0.7727.116","safari/18.7.2","safari/15.6.1","safari/17.2","safari/18.4.1","edge/135.0.3179.54","safari/26.4.1","chrome/106.0.0.0","safari/16.1","chrome/91.0.4472.124","chrome/132.0.6788.76","safari/16.3"],"conditionalProbabilities":{"deeper":{"chrome/147.0.0.0|2":{"chrome/147.0.0.0":1},"edge/147.0.0.0|2":{"edge/147.0.0.0":1},"safari/26.4|2":{"safari/26.4":1},"chrome/147.0.0.0|1":{"chrome/147.0.0.0":1},"safari/26.3|2":{"safari/26.3":1},"chrome/146.0.0.0|2":{"chrome/146.0.0.0":1},"firefox/150.0|2":{"firefox/150.0":1},"safari/26.2|2":{"safari/26.2":1},"safari/18.5|2":{"safari/18.5":1},"safari/26.0.1|2":{"safari/26.0.1":1},"firefox/135.0|2":{"firefox/135.0":1},"chrome/148.0.0.0|2":{"chrome/148.0.0.0":1},"chrome/142.0.0.0|2":{"chrome/142.0.0.0":1},"safari/18.3|2":{"safari/18.3":1},"chrome/144.0.0.0|2":{"chrome/144.0.0.0":1},"safari/18.2|2":{"safari/18.2":1},"safari/26.3.1|2":{"safari/26.3.1":1},"safari/18.7|2":{"safari/18.7":1},"edge/129.0.0.0|2":{"edge/129.0.0.0":1},"safari/18.7.4|2":{"safari/18.7.4":1},"safari/18.1|2":{"safari/18.1":1},"safari/18.6|2":{"safari/18.6":1},"safari/18.7.5|2":{"safari/18.7.5":1},"chrome/145.0.0.0|2":{"chrome/145.0.0.0":1},"chrome/127.0.0.0|2":{"chrome/127.0.0.0":1},"safari/16.6|2":{"safari/16.6":1},"chrome/141.0.0.0|2":{"chrome/141.0.0.0":1},"edge/147.0.0.0|1":{"edge/147.0.0.0":1},"chrome/121.0.0.0|2":{"chrome/121.0.0.0":1},"chrome/143.0.0.0|2":{"chrome/143.0.0.0":1},"chrome/128.0.0.0|2":{"chrome/128.0.0.0":1},"safari/17.2.1|2":{"safari/17.2.1":1},"safari/26.5|2":{"safari/26.5":1},"chrome/116.0.0.0|2":{"chrome/116.0.0.0":1},"chrome/138.0.0.0|2":{"chrome/138.0.0.0":1},"safari/26.1|2":{"safari/26.1":1},"safari/17.6|2":{"safari/17.6":1},"firefox/151.0|2":{"firefox/151.0":1},"chrome/130.0.0.0|2":{"chrome/130.0.0.0":1},"chrome/115.0.0.0|2":{"chrome/115.0.0.0":1},"edge/146.0.0.0|2":{"edge/146.0.0.0":1},"chrome/149.0.0.0|2":{"chrome/149.0.0.0":1},"chrome/140.0.0.0|2":{"chrome/140.0.0.0":1},"safari/16.4|2":{"safari/16.4":1},"safari/18.1.1|2":{"safari/18.1.1":1},"edge/144.0.0.0|2":{"edge/144.0.0.0":1},"edge/128.0.0.0|2":{"edge/128.0.0.0":1},"chrome/109.0.0.0|2":{"chrome/109.0.0.0":1},"chrome/139.0.0.0|2":{"chrome/139.0.0.0":1},"edge/143.0.0.0|2":{"edge/143.0.0.0":1},"edge/146.0.0.0|1":{"edge/146.0.0.0":1},"safari/26.4|1":{"safari/26.4":1},"safari/17.4|2":{"safari/17.4":1},"chrome/143.0.0.0|1":{"chrome/143.0.0.0":1},"chrome/131.0.0.0|2":{"chrome/131.0.0.0":1},"safari/18.4|2":{"safari/18.4":1},"safari/18.0.1|2":{"safari/18.0.1":1},"chrome/134.0.0.0|2":{"chrome/134.0.0.0":1},"safari/16.5.2|2":{"safari/16.5.2":1},"safari/26.4.2|2":{"safari/26.4.2":1},"chrome/120.0.0.0|2":{"chrome/120.0.0.0":1},"chrome/137.0.0.0|2":{"chrome/137.0.0.0":1},"safari/26.0|2":{"safari/26.0":1},"safari/16.6.1|2":{"safari/16.6.1":1},"firefox/147.0|2":{"firefox/147.0":1},"edge/145.0.0.0|2":{"edge/145.0.0.0":1},"chrome/135.0.0.0|2":{"chrome/135.0.0.0":1},"safari/18.5|1":{"safari/18.5":1},"safari/17.5|2":{"safari/17.5":1},"chrome/45.0.8909.1591|2":{"chrome/45.0.8909.1591":1},"chrome/145.0.0.0|1":{"chrome/145.0.0.0":1},"chrome/91.0.4450.0|2":{"chrome/91.0.4450.0":1},"safari/17.7|2":{"safari/17.7":1},"chrome/125.0.0.0|2":{"chrome/125.0.0.0":1},"chrome/126.0.0.0|2":{"chrome/126.0.0.0":1},"chrome/113.0.0.0|2":{"chrome/113.0.0.0":1},"chrome/101.0.4951.54|2":{"chrome/101.0.4951.54":1},"chrome/108.0.0.0|2":{"chrome/108.0.0.0":1},"edge/148.0.0.0|2":{"edge/148.0.0.0":1},"chrome/142.0.0.0|1":{"chrome/142.0.0.0":1},"chrome/147.0.7727.56|2":{"chrome/147.0.7727.56":1},"safari/17.4.1|2":{"safari/17.4.1":1},"edge/123.0.0.0|2":{"edge/123.0.0.0":1},"chrome/136.0.0.0|2":{"chrome/136.0.0.0":1},"safari/16.6.2|2":{"safari/16.6.2":1},"chrome/119.0.0.0|2":{"chrome/119.0.0.0":1},"chrome/142.0.7444.138|2":{"chrome/142.0.7444.138":1},"safari/18.3.1|2":{"safari/18.3.1":1},"edge/138.0.0.0|2":{"edge/138.0.0.0":1},"chrome/146.0.0.0|1":{"chrome/146.0.0.0":1},"firefox/136.0|2":{"firefox/136.0":1},"chrome/114.0.0.0|2":{"chrome/114.0.0.0":1},"safari/16.5|2":{"safari/16.5":1},"chrome/138.0.7204.235|2":{"chrome/138.0.7204.235":1},"chrome/59.0.9273.1293|2":{"chrome/59.0.9273.1293":1},"chrome/144.0.7559.95|2":{"chrome/144.0.7559.95":1},"safari/17.3|2":{"safari/17.3":1},"edge/142.0.0.0|2":{"edge/142.0.0.0":1},"safari/18.7.3|2":{"safari/18.7.3":1},"chrome/138.0.7204.63|2":{"chrome/138.0.7204.63":1},"firefox/149.0|2":{"firefox/149.0":1},"safari/17.8|2":{"safari/17.8":1},"safari/15.6.8|2":{"safari/15.6.8":1},"safari/17.3.1|2":{"safari/17.3.1":1},"chrome/147.0.7727.111|2":{"chrome/147.0.7727.111":1},"chrome/124.0.0.0|2":{"chrome/124.0.0.0":1},"chrome/122.0.0.0|2":{"chrome/122.0.0.0":1},"safari/15.6.7|2":{"safari/15.6.7":1},"chrome/107.0.0.0|2":{"chrome/107.0.0.0":1},"firefox/146.0|2":{"firefox/146.0":1},"edge/122.0.0.0|2":{"edge/122.0.0.0":1},"safari/16.2|2":{"safari/16.2":1},"chrome/103.0.0.0|2":{"chrome/103.0.0.0":1},"chrome/90.0.4430.212|2":{"chrome/90.0.4430.212":1},"safari/17.1.2|2":{"safari/17.1.2":1},"chrome/132.0.0.0|2":{"chrome/132.0.0.0":1},"safari/18.0|2":{"safari/18.0":1},"chrome/130.0.6723.73|2":{"chrome/130.0.6723.73":1},"chrome/133.0.0.0|2":{"chrome/133.0.0.0":1},"firefox/142.0|2":{"firefox/142.0":1},"safari/17.0|2":{"safari/17.0":1},"safari/17.1|2":{"safari/17.1":1},"chrome/96.0.4664.110|1":{"chrome/96.0.4664.110":1},"safari/18.7.7|2":{"safari/18.7.7":1},"chrome/147.0.7727.116|1":{"chrome/147.0.7727.116":1},"safari/18.7.2|2":{"safari/18.7.2":1},"safari/15.6.1|2":{"safari/15.6.1":1},"chrome/116.0.0.0|1":{"chrome/116.0.0.0":1},"safari/17.2|2":{"safari/17.2":1},"safari/18.4.1|2":{"safari/18.4.1":1},"edge/135.0.3179.54|2":{"edge/135.0.3179.54":1},"safari/26.4.1|2":{"safari/26.4.1":1},"chrome/106.0.0.0|2":{"chrome/106.0.0.0":1},"safari/16.1|2":{"safari/16.1":1},"chrome/91.0.4472.124|2":{"chrome/91.0.4472.124":1},"chrome/132.0.6788.76|2":{"chrome/132.0.6788.76":1},"safari/16.3|2":{"safari/16.3":1}}}}]} \ No newline at end of file diff --git a/browser/vendor/cssselect/Cargo.toml b/browser/vendor/cssselect/Cargo.toml new file mode 100644 index 000000000..588d551e3 --- /dev/null +++ b/browser/vendor/cssselect/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "cssselect" +version = "0.2.0" +edition = "2021" +license = "BSD-3-Clause" +description = "Repository-owned Scrapling compatibility fork of cssselect" + +[features] +default = ["std"] +std = [] + +[lib] +path = "src/lib.rs" + +[dev-dependencies] +sxd-document = "0.3" +sxd-xpath = "0.4" diff --git a/browser/vendor/cssselect/LICENSE b/browser/vendor/cssselect/LICENSE new file mode 100644 index 000000000..98531f7f2 --- /dev/null +++ b/browser/vendor/cssselect/LICENSE @@ -0,0 +1,32 @@ +Copyright (c) 2007-2012 Ian Bicking and contributors. See AUTHORS for +more details. + +Some rights reserved. + +Redistribution and use in source and binary forms of the software as well +as documentation, with or without modification, are permitted provided +that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +* The names of the contributors may not be used to endorse or promote + products derived from this software without specific prior written + permission. + +THIS SOFTWARE AND DOCUMENTATION IS PROVIDED BY THE COPYRIGHT HOLDERS AND +CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT +NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER +OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE AND DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. diff --git a/browser/vendor/cssselect/README.md b/browser/vendor/cssselect/README.md new file mode 100644 index 000000000..7e8eadf21 --- /dev/null +++ b/browser/vendor/cssselect/README.md @@ -0,0 +1,62 @@ +# cssselect + +Translate CSS3 selectors into XPath 1.0 expression strings. + +This crate parses a CSS group of selectors and emits XPath 1.0 strings. It does +no DOM matching and no I/O. The whole library is a pure string to string +transformation. It needs `alloc` but not `std`, so it builds for `no_std` and +`wasm32` targets with zero runtime dependencies. + +## Installation + +```toml +[dependencies] +cssselect = "0.1" +``` + +## Usage + +```rust +use cssselect::GenericTranslator; + +let xpath = GenericTranslator::new() + .css_to_xpath("a#bar") + .unwrap(); +assert_eq!(xpath, "descendant-or-self::a[@id = 'bar']"); +``` + +`GenericTranslator` targets generic XML. `HtmlTranslator` targets (X)HTML with +case-insensitive names and HTML-aware results for `:checked`, `:disabled`, +`:enabled`, `:link`, and `:lang()`. + +Call `parse` directly when you need the parsed tree, the canonical CSS form, or +the specificity: + +```rust +use cssselect::parse; + +let selector = &parse(":is(.foo, #bar)").unwrap()[0]; +assert_eq!(selector.canonical(), ":is(.foo, #bar)"); +assert_eq!(selector.specificity(), (1, 0, 0)); +``` + +## Supported selectors + +Type, universal, namespace, class, id, and attribute selectors with the `=`, +`~=`, `|=`, `^=`, `$=`, `*=`, and `!=` operators. Structural pseudo-classes +including the `:nth-*` family, `:not()`, `:is()`, `:matches()`, `:where()`, +`:has()`, and `:scope`. Pseudo-elements with one or two colons. The four +combinators: descendant, `>`, `+`, and `~`. + +## Tests + +`cargo test` runs the string-parity suite with no dependencies. The optional +selection tier runs generated XPath against a real engine: + +```sh +cargo test --features xpath-engine-tests +``` + +## License + +Licensed under the [BSD 3-Clause license](LICENSE). diff --git a/browser/vendor/cssselect/src/error.rs b/browser/vendor/cssselect/src/error.rs new file mode 100644 index 000000000..77a46bfa9 --- /dev/null +++ b/browser/vendor/cssselect/src/error.rs @@ -0,0 +1,29 @@ +//! Error type for parsing and translation. + +use alloc::string::String; +use core::fmt; + +/// Failure from parsing a selector or translating it to XPath. +/// +/// `Syntax` comes from the parser when the grammar is wrong. `Expression` +/// comes from the translator when a selector is valid but cannot be expressed +/// in XPath 1.0 or names an unknown pseudo-class. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SelectorError { + /// A parse-time grammar error. The message matches the parser output. + Syntax(String), + /// A translate-time error. The message matches the translator output. + Expression(String), +} + +impl fmt::Display for SelectorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SelectorError::Syntax(msg) => f.write_str(msg), + SelectorError::Expression(msg) => f.write_str(msg), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for SelectorError {} diff --git a/browser/vendor/cssselect/src/html.rs b/browser/vendor/cssselect/src/html.rs new file mode 100644 index 000000000..059b128e4 --- /dev/null +++ b/browser/vendor/cssselect/src/html.rs @@ -0,0 +1,88 @@ +//! (X)HTML translator. +//! +//! This translator reuses the generic engine with HTML-specific settings. It +//! folds element and attribute names to lower case unless built for XHTML, uses +//! the `lang` attribute for `:lang()`, and gives HTML-aware results for +//! `:checked`, `:disabled`, `:enabled`, and `:link`. + +use alloc::string::String; + +use crate::error::SelectorError; +use crate::parser::Selector; +use crate::xpath::{css_to_xpath, selector_to_xpath, Config, PseudoElements}; + +/// Translator that targets (X)HTML. +/// +/// Folds element and attribute names to lower case unless built for XHTML. +#[derive(Debug, Clone)] +pub struct HtmlTranslator { + config: Config, + xhtml: bool, +} + +impl Default for HtmlTranslator { + fn default() -> Self { + HtmlTranslator::new() + } +} + +impl HtmlTranslator { + /// Create an HTML translator. Element and attribute names fold to lower case. + pub fn new() -> Self { + HtmlTranslator::with_xhtml(false) + } + + /// Create a translator with the XHTML flag set as given. + /// + /// With `xhtml = true`, names stay case sensitive. + pub fn with_xhtml(xhtml: bool) -> Self { + HtmlTranslator { + config: Config::html(xhtml), + xhtml, + } + } + + /// Whether this translator treats input as XHTML. + pub fn is_xhtml(&self) -> bool { + self.xhtml + } + + /// Translate a CSS group of selectors to an XPath string. + /// + /// The default prefix scopes selectors to the context node's subtree. + pub fn css_to_xpath(&self, css: &str) -> Result<String, SelectorError> { + css_to_xpath(&self.config, css, "descendant-or-self::") + } + + /// Translate a CSS group of selectors using an explicit prefix. + pub fn css_to_xpath_with_prefix( + &self, + css: &str, + prefix: &str, + ) -> Result<String, SelectorError> { + css_to_xpath(&self.config, css, prefix) + } + + /// Translate a single parsed selector to an XPath string. + /// + /// The pseudo-element is ignored. + pub fn selector_to_xpath(&self, selector: &Selector) -> Result<String, SelectorError> { + selector_to_xpath( + &self.config, + selector, + "descendant-or-self::", + PseudoElements::Ignore, + ) + } + + /// Translate a single parsed selector with an explicit prefix and + /// pseudo-element handling. + pub fn selector_to_xpath_with( + &self, + selector: &Selector, + prefix: &str, + pseudo_elements: PseudoElements, + ) -> Result<String, SelectorError> { + selector_to_xpath(&self.config, selector, prefix, pseudo_elements) + } +} diff --git a/browser/vendor/cssselect/src/lib.rs b/browser/vendor/cssselect/src/lib.rs new file mode 100644 index 000000000..030bb2b26 --- /dev/null +++ b/browser/vendor/cssselect/src/lib.rs @@ -0,0 +1,52 @@ +//! Translate CSS3 selectors into XPath 1.0 expression strings. +//! +//! This crate parses a CSS group of selectors into an abstract syntax tree and +//! translates each selector into an XPath 1.0 string. It does no DOM matching +//! and no I/O. The whole library is a pure string to string transformation. +//! +//! Two translators are available. [`GenericTranslator`] targets generic XML and +//! is fully case sensitive. [`HtmlTranslator`] targets (X)HTML, folds element +//! and attribute names to lower case unless built with `xhtml = true`, and gives +//! HTML aware results for `:checked`, `:disabled`, `:enabled`, `:link`, and +//! `:lang()`. +//! +//! # Example +//! +//! ``` +//! use cssselect::GenericTranslator; +//! +//! let xpath = GenericTranslator::new() +//! .css_to_xpath("div.foo > a#bar") +//! .unwrap(); +//! assert_eq!( +//! xpath, +//! "descendant-or-self::div[@class and contains(\ +//! concat(' ', normalize-space(@class), ' '), ' foo ')]/a[@id = 'bar']" +//! ); +//! ``` +//! +//! The crate is `no_std` friendly. It needs `alloc` but not `std`. Build with +//! `--no-default-features` to drop the `std` feature. +#![no_std] +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +extern crate alloc; + +#[cfg(feature = "std")] +extern crate std; + +pub mod error; +pub mod html; +pub mod parser; +pub mod tokenizer; +mod util; +pub mod xpath; + +pub use error::SelectorError; +pub use html::HtmlTranslator; +pub use parser::{ + parse, parse_series, FunctionalPseudoElement, PseudoElement, Selector, Specificity, Tree, +}; +pub use tokenizer::{tokenize, Token, TokenType}; +pub use xpath::{xpath_literal, Config, GenericTranslator, PseudoElements, XpathExpr}; diff --git a/browser/vendor/cssselect/src/parser.rs b/browser/vendor/cssselect/src/parser.rs new file mode 100644 index 000000000..5eebd698d --- /dev/null +++ b/browser/vendor/cssselect/src/parser.rs @@ -0,0 +1,1181 @@ +//! Tokenizer-driven parser, the AST node types, and the `parse` entry point. +//! +//! The parser turns a CSS group of selectors into a list of [`Selector`] +//! values. Each selector wraps a [`Tree`] and an optional pseudo-element. The +//! tree nodes carry the `repr`, `canonical`, and `specificity` behavior the +//! tests pin. + +use alloc::boxed::Box; +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use crate::error::SelectorError; +use crate::tokenizer::{tokenize, Token, TokenType}; + +/// A specificity triple `(a, b, c)`: IDs, then classes and attributes and +/// pseudo-classes, then types and pseudo-elements. +pub type Specificity = (u32, u32, u32); + +/// A pseudo-element on a selector: a plain identifier or a functional form. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PseudoElement { + /// A plain pseudo-element identifier such as `before`. + Ident(String), + /// A functional pseudo-element such as `attr(name)`. + Functional(FunctionalPseudoElement), +} + +/// A functional pseudo-element such as `::name(args)`. +/// +/// The name is ASCII lower-cased. The arguments are the raw tokens between the +/// parentheses. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FunctionalPseudoElement { + /// The pseudo-element name, ASCII lower-cased. + pub name: String, + /// The argument tokens. + pub arguments: Vec<Token>, +} + +impl FunctionalPseudoElement { + /// Build a functional pseudo-element, lower-casing the name. + pub fn new(name: &str, arguments: Vec<Token>) -> FunctionalPseudoElement { + FunctionalPseudoElement { + name: name.to_ascii_lowercase(), + arguments, + } + } + + /// The type names of the argument tokens. + pub fn argument_types(&self) -> Vec<&'static str> { + self.arguments.iter().map(|t| t.ty.name()).collect() + } + + /// The CSS serialization, such as `attr(name)`. + pub fn canonical(&self) -> String { + let args: String = self.arguments.iter().map(|t| t.to_css()).collect(); + format!("{}({})", self.name, args) + } + + /// The Python `repr` form used inside selector `repr` output. + pub fn repr(&self) -> String { + format!( + "FunctionalPseudoElement[::{}({})]", + self.name, + repr_token_values(&self.arguments) + ) + } +} + +/// Render a token-value list the way Python renders `[t.value for t in args]`. +fn repr_token_values(tokens: &[Token]) -> String { + let mut out = String::from("["); + for (i, t) in tokens.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + out.push_str(&crate::util::py_repr(t.value_str())); + } + out.push(']'); + out +} + +/// A parsed selector tree node. +/// +/// The variants cover every selector construct the grammar accepts. Each node +/// answers `repr`, `canonical`, and `specificity`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Tree { + /// `namespace|element`, with `None` element meaning the universal `*`. + Element { + /// The namespace prefix, if any. + namespace: Option<String>, + /// The element name, or `None` for `*`. + element: Option<String>, + }, + /// `selector#id`. + Hash { + /// The selector this hash applies to. + selector: Box<Tree>, + /// The id value. + id: String, + }, + /// `selector.class_name`. + Class { + /// The selector this class applies to. + selector: Box<Tree>, + /// The class name. + class_name: String, + }, + /// `selector[ns|attrib op value]`. + Attrib { + /// The selector this attribute test applies to. + selector: Box<Tree>, + /// The attribute namespace, if any. + namespace: Option<String>, + /// The attribute name. + attrib: String, + /// The operator, such as `=`, `^=`, or `exists`. + operator: String, + /// The value token, or `None` for the `exists` operator. + value: Option<Token>, + }, + /// `selector:ident`. + Pseudo { + /// The selector this pseudo-class applies to. + selector: Box<Tree>, + /// The pseudo-class identifier, ASCII lower-cased. + ident: String, + }, + /// `selector:name(args)`. + Function { + /// The selector this functional pseudo-class applies to. + selector: Box<Tree>, + /// The function name, ASCII lower-cased. + name: String, + /// The argument tokens. + arguments: Vec<Token>, + }, + /// `selector:not(subselector)`. + Negation { + /// The selector being negated against. + selector: Box<Tree>, + /// The negated subselector. + subselector: Box<Tree>, + }, + /// `selector:has(subselector)`. + Relation { + /// The selector this relation applies to. + selector: Box<Tree>, + /// The combinator token. + combinator: Token, + /// The relative subselector. + subselector: Box<Selector>, + }, + /// `selector:is(selector_list)`, also `:matches`. + Matching { + /// The selector this match applies to. + selector: Box<Tree>, + /// The selector list. + selector_list: Vec<Tree>, + }, + /// `selector:where(selector_list)`. + SpecificityAdjustment { + /// The selector this adjustment applies to. + selector: Box<Tree>, + /// The selector list. + selector_list: Vec<Tree>, + }, + /// `selector combinator subselector`. + Combined { + /// The left selector. + selector: Box<Tree>, + /// The combinator, one of `" "`, `">"`, `"+"`, `"~"`. + combinator: String, + /// The right selector. + subselector: Box<Tree>, + }, +} + +impl Tree { + /// The Python `repr` of this node. + pub fn repr(&self) -> String { + match self { + Tree::Element { .. } => format!("Element[{}]", self.canonical()), + Tree::Hash { selector, id } => format!("Hash[{}#{}]", selector.repr(), id), + Tree::Class { + selector, + class_name, + } => format!("Class[{}.{}]", selector.repr(), class_name), + Tree::Attrib { + selector, + namespace, + attrib, + operator, + value, + } => { + let attr = match namespace { + Some(ns) => format!("{ns}|{attrib}"), + None => attrib.clone(), + }; + if operator == "exists" { + format!("Attrib[{}[{}]]", selector.repr(), attr) + } else { + let v = value.as_ref().map(|t| t.value_str()).unwrap_or(""); + format!( + "Attrib[{}[{} {} {}]]", + selector.repr(), + attr, + operator, + crate::util::py_repr(v) + ) + } + } + Tree::Pseudo { selector, ident } => { + format!("Pseudo[{}:{}]", selector.repr(), ident) + } + Tree::Function { + selector, + name, + arguments, + } => format!( + "Function[{}:{}({})]", + selector.repr(), + name, + repr_token_values(arguments) + ), + Tree::Negation { + selector, + subselector, + } => format!("Negation[{}:not({})]", selector.repr(), subselector.repr()), + Tree::Relation { + selector, + subselector, + .. + } => format!("Relation[{}:has({})]", selector.repr(), subselector.repr()), + Tree::Matching { + selector, + selector_list, + } => { + let inner = join_reprs(selector_list); + format!("Matching[{}:is({})]", selector.repr(), inner) + } + Tree::SpecificityAdjustment { + selector, + selector_list, + } => { + let inner = join_reprs(selector_list); + format!( + "SpecificityAdjustment[{}:where({})]", + selector.repr(), + inner + ) + } + Tree::Combined { + selector, + combinator, + subselector, + } => { + let comb = if combinator == " " { + "<followed>" + } else { + combinator.as_str() + }; + format!( + "CombinedSelector[{} {} {}]", + selector.repr(), + comb, + subselector.repr() + ) + } + } + } + + /// The CSS serialization of this node. + pub fn canonical(&self) -> String { + match self { + Tree::Element { namespace, element } => { + let el = element.as_deref().unwrap_or("*"); + match namespace { + Some(ns) => format!("{ns}|{el}"), + None => el.to_string(), + } + } + Tree::Hash { selector, id } => format!("{}#{}", selector.canonical(), id), + Tree::Class { + selector, + class_name, + } => format!("{}.{}", selector.canonical(), class_name), + Tree::Attrib { + selector, + namespace, + attrib, + operator, + value, + } => { + let attr = match namespace { + Some(ns) => format!("{ns}|{attrib}"), + None => attrib.clone(), + }; + let op = if operator == "exists" { + attr + } else { + let v = value.as_ref().map(|t| t.to_css()).unwrap_or_default(); + format!("{attr}{operator}{v}") + }; + format!("{}[{}]", selector.canonical(), op) + } + Tree::Pseudo { selector, ident } => { + format!("{}:{}", selector.canonical(), ident) + } + Tree::Function { + selector, + name, + arguments, + } => { + let args: String = arguments.iter().map(|t| t.to_css()).collect(); + format!("{}:{}({})", selector.canonical(), name, args) + } + Tree::Negation { + selector, + subselector, + } => { + let mut subsel = subselector.canonical(); + if subsel.chars().count() > 1 { + subsel = lstrip_star(&subsel); + } + format!("{}:not({})", selector.canonical(), subsel) + } + Tree::Relation { + selector, + subselector, + .. + } => { + let mut subsel = subselector.canonical(); + if subsel.chars().count() > 1 { + subsel = lstrip_star(&subsel); + } + format!("{}:has({})", selector.canonical(), subsel) + } + Tree::Matching { + selector, + selector_list, + } => { + let inner = join_canonical_stripped(selector_list); + format!("{}:is({})", selector.canonical(), inner) + } + Tree::SpecificityAdjustment { + selector, + selector_list, + } => { + let inner = join_canonical_stripped(selector_list); + format!("{}:where({})", selector.canonical(), inner) + } + Tree::Combined { + selector, + combinator, + subselector, + } => { + let mut subsel = subselector.canonical(); + if subsel.chars().count() > 1 { + subsel = lstrip_star(&subsel); + } + format!("{} {} {}", selector.canonical(), combinator, subsel) + } + } + } + + /// The specificity triple of this node. + pub fn specificity(&self) -> Specificity { + match self { + Tree::Element { element, .. } => { + if element.is_some() { + (0, 0, 1) + } else { + (0, 0, 0) + } + } + Tree::Hash { selector, .. } => { + let (a, b, c) = selector.specificity(); + (a + 1, b, c) + } + Tree::Class { selector, .. } + | Tree::Attrib { selector, .. } + | Tree::Pseudo { selector, .. } + | Tree::Function { selector, .. } => { + let (a, b, c) = selector.specificity(); + (a, b + 1, c) + } + Tree::Negation { + selector, + subselector, + } => add_spec(selector.specificity(), subselector.specificity()), + Tree::Relation { + selector, + subselector, + .. + } => add_spec( + selector.specificity(), + subselector.parsed_tree.specificity(), + ), + Tree::Matching { + selector, + selector_list, + } => add_spec(selector.specificity(), max_spec(selector_list)), + Tree::SpecificityAdjustment { selector, .. } => selector.specificity(), + Tree::Combined { + selector, + subselector, + .. + } => add_spec(selector.specificity(), subselector.specificity()), + } + } +} + +/// Strip leading `*` characters, matching Python `str.lstrip("*")`. +fn lstrip_star(s: &str) -> String { + s.trim_start_matches('*').to_string() +} + +/// Component-wise sum of two specificity triples. +fn add_spec(x: Specificity, y: Specificity) -> Specificity { + (x.0 + y.0, x.1 + y.1, x.2 + y.2) +} + +/// Lexicographic maximum of a selector list's specificity triples. +fn max_spec(list: &[Tree]) -> Specificity { + list.iter() + .map(|t| t.specificity()) + .max() + .unwrap_or((0, 0, 0)) +} + +/// Join the `repr` of each tree with `, `. +fn join_reprs(list: &[Tree]) -> String { + let mut out = String::new(); + for (i, t) in list.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + out.push_str(&t.repr()); + } + out +} + +/// Join each tree's canonical form with `, `, stripping a leading `*` from each. +fn join_canonical_stripped(list: &[Tree]) -> String { + let mut out = String::new(); + for (i, t) in list.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + out.push_str(&lstrip_star(&t.canonical())); + } + out +} + +/// A parsed selector: a tree plus an optional pseudo-element. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Selector { + /// The parsed selector tree. + pub parsed_tree: Tree, + /// The pseudo-element, if any. + pub pseudo_element: Option<PseudoElement>, +} + +impl Selector { + /// Build a selector, ASCII lower-casing a plain pseudo-element name. + pub fn new(tree: Tree, pseudo_element: Option<PseudoElement>) -> Selector { + let pseudo_element = pseudo_element.map(|pe| match pe { + PseudoElement::Ident(name) => PseudoElement::Ident(name.to_ascii_lowercase()), + other => other, + }); + Selector { + parsed_tree: tree, + pseudo_element, + } + } + + /// The Python `repr` of this selector. + pub fn repr(&self) -> String { + let pe = match &self.pseudo_element { + Some(PseudoElement::Functional(f)) => f.repr(), + Some(PseudoElement::Ident(name)) if !name.is_empty() => format!("::{name}"), + _ => String::new(), + }; + format!("Selector[{}{}]", self.parsed_tree.repr(), pe) + } + + /// The CSS serialization of this selector. + /// + /// A leading `*` is stripped when the result is longer than one character. + pub fn canonical(&self) -> String { + let pe = match &self.pseudo_element { + Some(PseudoElement::Functional(f)) => format!("::{}", f.canonical()), + Some(PseudoElement::Ident(name)) if !name.is_empty() => format!("::{name}"), + _ => String::new(), + }; + let mut res = format!("{}{}", self.parsed_tree.canonical(), pe); + if res.chars().count() > 1 { + res = lstrip_star(&res); + } + res + } + + /// The specificity of this selector. A pseudo-element adds one to `c`. + pub fn specificity(&self) -> Specificity { + let (a, b, mut c) = self.parsed_tree.specificity(); + if self.pseudo_element.is_some() { + c += 1; + } + (a, b, c) + } +} + +/// Parse the arguments for `:nth-child()` and friends into an `(a, b)` pair. +/// +/// Returns an error when a string token appears or a coefficient does not parse +/// as an integer. The caller turns the error into an expression error. +pub fn parse_series(tokens: &[Token]) -> Result<(i64, i64), SelectorError> { + for t in tokens { + if t.ty == TokenType::String { + return Err(SelectorError::Syntax( + "String tokens not allowed in series.".to_string(), + )); + } + } + let joined: String = tokens.iter().map(|t| t.value_str()).collect(); + let s = joined.trim(); + match s { + "odd" => return Ok((2, 1)), + "even" => return Ok((2, 0)), + "n" => return Ok((1, 0)), + _ => {} + } + if !s.contains('n') { + let b = parse_int(s)?; + return Ok((0, b)); + } + let idx = s + .find('n') + .expect("series text contains 'n' on this branch"); + let a_part = &s[..idx]; + let b_part = &s[idx + 1..]; + let a = if a_part.is_empty() { + 1 + } else if a_part == "-" || a_part == "+" { + parse_int(&format!("{a_part}1"))? + } else { + parse_int(a_part)? + }; + let b = if b_part.is_empty() { + 0 + } else { + parse_int(b_part)? + }; + Ok((a, b)) +} + +/// Parse a signed integer with the same acceptance as Python `int(str)`. +/// +/// A single leading `+` or `-` is allowed, then digits. No surrounding +/// whitespace, since the series text was already trimmed. A value that does not +/// fit in `i64` returns a series error rather than overflowing, so a long digit +/// run in an `:nth-*` argument cannot panic the translator. +fn parse_int(s: &str) -> Result<i64, SelectorError> { + let digits = match s.as_bytes().first() { + Some(b'+') | Some(b'-') => &s[1..], + _ => s, + }; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return Err(series_error()); + } + s.parse::<i64>().map_err(|_| series_error()) +} + +/// The error raised when a series coefficient does not parse. +fn series_error() -> SelectorError { + SelectorError::Syntax("Invalid series".to_string()) +} + +/// The token stream the parser reads from. +/// +/// `used` records consumed tokens. The `:scope` placement guard and the +/// "Expected selector" check both depend on its length. Peeking does not add to +/// `used`. +struct TokenStream { + tokens: Vec<Token>, + index: usize, + used: Vec<Token>, +} + +impl TokenStream { + fn new(tokens: Vec<Token>) -> TokenStream { + TokenStream { + tokens, + index: 0, + used: Vec::new(), + } + } + + /// Consume and return the next token, recording it in `used`. + fn next(&mut self) -> Token { + let token = self.peek_token(); + self.index += 1; + self.used.push(token.clone()); + token + } + + /// Look at the next token without consuming it. + fn peek(&self) -> Token { + self.peek_token() + } + + /// The next token, clamped to the final EOF token. + fn peek_token(&self) -> Token { + if self.index < self.tokens.len() { + self.tokens[self.index].clone() + } else { + self.tokens[self.tokens.len() - 1].clone() + } + } + + /// Consume an identifier or report an error. + fn next_ident(&mut self) -> Result<String, SelectorError> { + let t = self.next(); + if t.ty != TokenType::Ident { + return Err(SelectorError::Syntax(format!("Expected ident, got {t}"))); + } + Ok(t.value_str().to_string()) + } + + /// Consume an identifier or `*`, returning `None` for `*`. + fn next_ident_or_star(&mut self) -> Result<Option<String>, SelectorError> { + let t = self.next(); + if t.ty == TokenType::Ident { + Ok(Some(t.value_str().to_string())) + } else if t.matches(TokenType::Delim, "*") { + Ok(None) + } else { + Err(SelectorError::Syntax(format!( + "Expected ident or '*', got {t}" + ))) + } + } + + /// Skip a single whitespace token if present. + fn skip_whitespace(&mut self) { + if self.peek().ty == TokenType::S { + self.next(); + } + } +} + +/// Parse a CSS group of selectors into one [`Selector`] per comma-separated part. +/// +/// Returns a [`SelectorError::Syntax`] on an invalid selector. +pub fn parse(css: &str) -> Result<Vec<Selector>, SelectorError> { + // Fast paths for the most common simple selectors. They produce the same + // trees as the full parser but skip tokenizing and never error on the + // whitespace-padded forms. + if let Some(el) = fast_element(css) { + return Ok(alloc::vec![Selector::new( + Tree::Element { + namespace: None, + element: Some(el), + }, + None + )]); + } + if let Some((el, id)) = fast_hash(css) { + let element = if el.is_empty() { None } else { Some(el) }; + return Ok(alloc::vec![Selector::new( + Tree::Hash { + selector: Box::new(Tree::Element { + namespace: None, + element, + }), + id, + }, + None + )]); + } + if let Some((el, class)) = fast_class(css) { + let element = if el.is_empty() { None } else { Some(el) }; + return Ok(alloc::vec![Selector::new( + Tree::Class { + selector: Box::new(Tree::Element { + namespace: None, + element, + }), + class_name: class, + }, + None + )]); + } + + let tokens = tokenize(css)?; + let mut stream = TokenStream::new(tokens); + parse_selector_group(&mut stream) +} + +/// Match the `_el_re` fast path: optional whitespace, ASCII letters, whitespace. +fn fast_element(css: &str) -> Option<String> { + let body = css.trim_matches(is_fast_ws); + if !body.is_empty() && body.bytes().all(|b| b.is_ascii_alphabetic()) { + Some(body.to_string()) + } else { + None + } +} + +/// Match the `_id_re` fast path: `[a-zA-Z]*#[a-zA-Z0-9_-]+`. +fn fast_hash(css: &str) -> Option<(String, String)> { + let body = css.trim_matches(is_fast_ws); + let hash = body.find('#')?; + let el = &body[..hash]; + let id = &body[hash + 1..]; + if el.bytes().all(|b| b.is_ascii_alphabetic()) && !id.is_empty() && id.bytes().all(is_id_char) { + Some((el.to_string(), id.to_string())) + } else { + None + } +} + +/// Match the `_class_re` fast path: `[a-zA-Z]*\.[a-zA-Z][a-zA-Z0-9_-]*`. +fn fast_class(css: &str) -> Option<(String, String)> { + let body = css.trim_matches(is_fast_ws); + let dot = body.find('.')?; + let el = &body[..dot]; + let class = &body[dot + 1..]; + let cb = class.as_bytes(); + if el.bytes().all(|b| b.is_ascii_alphabetic()) + && !class.is_empty() + && cb[0].is_ascii_alphabetic() + && class.bytes().all(is_id_char) + { + Some((el.to_string(), class.to_string())) + } else { + None + } +} + +/// Whitespace characters the fast-path regexes trim. +fn is_fast_ws(c: char) -> bool { + matches!(c, ' ' | '\t' | '\r' | '\n' | '\u{0c}') +} + +/// Characters allowed in the id and class fast paths after the first. +fn is_id_char(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || b == b'-' +} + +/// Parse a comma-separated group of selectors. +fn parse_selector_group(stream: &mut TokenStream) -> Result<Vec<Selector>, SelectorError> { + let mut selectors = Vec::new(); + stream.skip_whitespace(); + loop { + let (tree, pseudo) = parse_selector(stream)?; + selectors.push(Selector::new(tree, pseudo)); + if stream.peek().matches(TokenType::Delim, ",") { + stream.next(); + stream.skip_whitespace(); + } else { + break; + } + } + Ok(selectors) +} + +/// Parse one selector, including combinators. +fn parse_selector( + stream: &mut TokenStream, +) -> Result<(Tree, Option<PseudoElement>), SelectorError> { + let (mut result, mut pseudo_element) = parse_simple_selector(stream, false)?; + loop { + stream.skip_whitespace(); + let peek = stream.peek(); + if peek.matches(TokenType::Eof, "") || peek.matches(TokenType::Delim, ",") { + break; + } + if let Some(pe) = &pseudo_element { + return Err(not_at_end_error(pe)); + } + let combinator = if peek.is_delim(&["+", ">", "~"]) { + let c = stream.next().value_str().to_string(); + stream.skip_whitespace(); + c + } else { + " ".to_string() + }; + let (next_selector, next_pseudo) = parse_simple_selector(stream, false)?; + pseudo_element = next_pseudo; + result = Tree::Combined { + selector: Box::new(result), + combinator, + subselector: Box::new(next_selector), + }; + } + Ok((result, pseudo_element)) +} + +/// The error for a pseudo-element that is not at the end of a selector. +fn not_at_end_error(pe: &PseudoElement) -> SelectorError { + SelectorError::Syntax(format!( + "Got pseudo-element ::{} not at the end of a selector", + pseudo_element_name(pe) + )) +} + +/// The display name of a pseudo-element for error messages. +fn pseudo_element_name(pe: &PseudoElement) -> String { + match pe { + PseudoElement::Ident(name) => name.clone(), + PseudoElement::Functional(f) => f.repr(), + } +} + +/// Parse a simple selector: an optional type or universal selector followed by +/// any number of qualifiers (hash, class, attribute, pseudo-class). +fn parse_simple_selector( + stream: &mut TokenStream, + inside_negation: bool, +) -> Result<(Tree, Option<PseudoElement>), SelectorError> { + stream.skip_whitespace(); + let selector_start = stream.used.len(); + let peek = stream.peek(); + + let (mut namespace, mut element): (Option<String>, Option<String>) = + if peek.ty == TokenType::Ident || peek.matches(TokenType::Delim, "*") { + let ns = if peek.ty == TokenType::Ident { + Some(stream.next().value_str().to_string()) + } else { + stream.next(); + None + }; + if stream.peek().matches(TokenType::Delim, "|") { + stream.next(); + let el = stream.next_ident_or_star()?; + (ns, el) + } else { + (None, ns) + } + } else { + (None, None) + }; + + let mut result = Tree::Element { + namespace: namespace.take(), + element: element.take(), + }; + let mut pseudo_element: Option<PseudoElement> = None; + + loop { + let peek = stream.peek(); + if peek.ty == TokenType::S + || peek.ty == TokenType::Eof + || peek.is_delim(&[",", "+", ">", "~"]) + || (inside_negation && peek.matches(TokenType::Delim, ")")) + { + break; + } + if let Some(pe) = &pseudo_element { + return Err(not_at_end_error(pe)); + } + if peek.ty == TokenType::Hash { + let id = stream.next().value_str().to_string(); + result = Tree::Hash { + selector: Box::new(result), + id, + }; + } else if peek.matches(TokenType::Delim, ".") { + stream.next(); + let class_name = stream.next_ident()?; + result = Tree::Class { + selector: Box::new(result), + class_name, + }; + } else if peek.matches(TokenType::Delim, "|") { + stream.next(); + let el = stream.next_ident()?; + result = Tree::Element { + namespace: None, + element: Some(el), + }; + } else if peek.matches(TokenType::Delim, "[") { + stream.next(); + result = parse_attrib(result, stream)?; + } else if peek.matches(TokenType::Delim, ":") { + stream.next(); + if stream.peek().matches(TokenType::Delim, ":") { + stream.next(); + let name = stream.next_ident()?; + if stream.peek().matches(TokenType::Delim, "(") { + stream.next(); + let args = parse_arguments(stream)?; + pseudo_element = Some(PseudoElement::Functional(FunctionalPseudoElement::new( + &name, args, + ))); + } else { + pseudo_element = Some(PseudoElement::Ident(name)); + } + continue; + } + let ident = stream.next_ident()?; + let lowered = ident.to_ascii_lowercase(); + if matches!( + lowered.as_str(), + "first-line" | "first-letter" | "before" | "after" + ) { + pseudo_element = Some(PseudoElement::Ident(ident)); + continue; + } + if !stream.peek().matches(TokenType::Delim, "(") { + result = Tree::Pseudo { + selector: Box::new(result), + ident: lowered.clone(), + }; + if result.repr() == "Pseudo[Element[*]:scope]" && !scope_allowed(stream) { + return Err(SelectorError::Syntax( + "Got immediate child pseudo-element \":scope\" not at the start of a selector" + .to_string(), + )); + } + continue; + } + stream.next(); + stream.skip_whitespace(); + match lowered.as_str() { + "not" => { + if inside_negation { + return Err(SelectorError::Syntax("Got nested :not()".to_string())); + } + let (argument, arg_pseudo) = parse_simple_selector(stream, true)?; + let next_ = stream.next(); + if let Some(pe) = &arg_pseudo { + return Err(SelectorError::Syntax(format!( + "Got pseudo-element ::{} inside :not() at {}", + pseudo_element_name(pe), + next_.pos + ))); + } + if !next_.matches(TokenType::Delim, ")") { + return Err(SelectorError::Syntax(format!("Expected ')', got {next_}"))); + } + result = Tree::Negation { + selector: Box::new(result), + subselector: Box::new(argument), + }; + } + "has" => { + let (combinator, arguments) = parse_relative_selector(stream)?; + result = Tree::Relation { + selector: Box::new(result), + combinator, + subselector: Box::new(arguments), + }; + } + "matches" | "is" => { + let selectors = parse_simple_selector_arguments(stream)?; + result = Tree::Matching { + selector: Box::new(result), + selector_list: selectors, + }; + } + "where" => { + let selectors = parse_simple_selector_arguments(stream)?; + result = Tree::SpecificityAdjustment { + selector: Box::new(result), + selector_list: selectors, + }; + } + _ => { + let args = parse_arguments(stream)?; + result = Tree::Function { + selector: Box::new(result), + name: lowered, + arguments: args, + }; + } + } + } else { + return Err(SelectorError::Syntax(format!( + "Expected selector, got {peek}" + ))); + } + } + + if stream.used.len() == selector_start { + return Err(SelectorError::Syntax(format!( + "Expected selector, got {}", + stream.peek() + ))); + } + Ok((result, pseudo_element)) +} + +/// Check the `:scope` placement rule against the consumed-token list. +/// +/// `:scope` is allowed only at the very start of a selector or right after a +/// comma. The patterns match the grammar guard exactly. +fn scope_allowed(stream: &TokenStream) -> bool { + let used = &stream.used; + let n = used.len(); + if n == 2 { + return true; + } + if n == 3 && used[0].ty == TokenType::S { + return true; + } + if n >= 3 && used[n - 3].is_delim(&[","]) { + return true; + } + if n >= 4 && used[n - 3].ty == TokenType::S && used[n - 4].is_delim(&[","]) { + return true; + } + false +} + +/// Collect tokens for a generic functional pseudo-class until the closing paren. +fn parse_arguments(stream: &mut TokenStream) -> Result<Vec<Token>, SelectorError> { + let mut arguments = Vec::new(); + loop { + stream.skip_whitespace(); + let next_ = stream.next(); + if matches!( + next_.ty, + TokenType::Ident | TokenType::String | TokenType::Number + ) || next_.is_delim(&["+", "-"]) + { + arguments.push(next_); + } else if next_.matches(TokenType::Delim, ")") { + return Ok(arguments); + } else { + return Err(SelectorError::Syntax(format!( + "Expected an argument, got {next_}" + ))); + } + } +} + +/// Parse the relative selector inside `:has()`. +/// +/// The accumulated text is re-parsed through [`parse`] and the first selector is +/// returned. The combinator defaults to a space delimiter token. +fn parse_relative_selector(stream: &mut TokenStream) -> Result<(Token, Selector), SelectorError> { + stream.skip_whitespace(); + let mut subselector = String::new(); + let mut next_ = stream.next(); + + let combinator = if next_.is_delim(&["+", "-", ">", "~"]) { + let c = next_.clone(); + stream.skip_whitespace(); + next_ = stream.next(); + c + } else { + Token::new(TokenType::Delim, " ", 0) + }; + + loop { + if matches!( + next_.ty, + TokenType::Ident | TokenType::String | TokenType::Number + ) || next_.is_delim(&[".", "*"]) + { + subselector.push_str(next_.value_str()); + } else if next_.matches(TokenType::Delim, ")") { + let result = parse(&subselector)?; + let first = result + .into_iter() + .next() + .expect("parse yields at least one selector or errors"); + return Ok((combinator, first)); + } else { + return Err(SelectorError::Syntax(format!( + "Expected an argument, got {next_}" + ))); + } + next_ = stream.next(); + } +} + +/// Parse the selector list inside `:is()`, `:matches()`, or `:where()`. +fn parse_simple_selector_arguments(stream: &mut TokenStream) -> Result<Vec<Tree>, SelectorError> { + let mut arguments = Vec::new(); + loop { + let (result, pseudo_element) = parse_simple_selector(stream, true)?; + if let Some(pe) = &pseudo_element { + return Err(SelectorError::Syntax(format!( + "Got pseudo-element ::{} inside function", + pseudo_element_name(pe) + ))); + } + stream.skip_whitespace(); + let next_ = stream.next(); + if next_.matches(TokenType::Eof, "") || next_.matches(TokenType::Delim, ",") { + stream.skip_whitespace(); + arguments.push(result); + } else if next_.matches(TokenType::Delim, ")") { + arguments.push(result); + break; + } else { + return Err(SelectorError::Syntax(format!( + "Expected an argument, got {next_}" + ))); + } + } + Ok(arguments) +} + +/// Parse the body of an attribute selector after the opening bracket. +fn parse_attrib(selector: Tree, stream: &mut TokenStream) -> Result<Tree, SelectorError> { + stream.skip_whitespace(); + let mut attrib = stream.next_ident_or_star()?; + if attrib.is_none() && !stream.peek().matches(TokenType::Delim, "|") { + return Err(SelectorError::Syntax(format!( + "Expected '|', got {}", + stream.peek() + ))); + } + let mut namespace: Option<String> = None; + let mut op: Option<String> = None; + if stream.peek().matches(TokenType::Delim, "|") { + stream.next(); + if stream.peek().matches(TokenType::Delim, "=") { + namespace = None; + stream.next(); + op = Some("|=".to_string()); + } else { + namespace = attrib.take(); + attrib = Some(stream.next_ident()?); + op = None; + } + } + + if op.is_none() { + stream.skip_whitespace(); + let next_ = stream.next(); + if next_.matches(TokenType::Delim, "]") { + return Ok(Tree::Attrib { + selector: Box::new(selector), + namespace, + attrib: attrib.unwrap_or_default(), + operator: "exists".to_string(), + value: None, + }); + } + if next_.matches(TokenType::Delim, "=") { + op = Some("=".to_string()); + } else if next_.is_delim(&["^", "$", "*", "~", "|", "!"]) + && stream.peek().matches(TokenType::Delim, "=") + { + op = Some(format!("{}=", next_.value_str())); + stream.next(); + } else { + return Err(SelectorError::Syntax(format!( + "Operator expected, got {next_}" + ))); + } + } + + stream.skip_whitespace(); + let value = stream.next(); + if !matches!(value.ty, TokenType::Ident | TokenType::String) { + return Err(SelectorError::Syntax(format!( + "Expected string or ident, got {value}" + ))); + } + stream.skip_whitespace(); + let next_ = stream.next(); + if !next_.matches(TokenType::Delim, "]") { + return Err(SelectorError::Syntax(format!("Expected ']', got {next_}"))); + } + Ok(Tree::Attrib { + selector: Box::new(selector), + namespace, + attrib: attrib.unwrap_or_default(), + operator: op.expect("an operator was set before reaching the value"), + value: Some(value), + }) +} diff --git a/browser/vendor/cssselect/src/tokenizer.rs b/browser/vendor/cssselect/src/tokenizer.rs new file mode 100644 index 000000000..447c7db9e --- /dev/null +++ b/browser/vendor/cssselect/src/tokenizer.rs @@ -0,0 +1,479 @@ +//! CSS tokenizer and the token stream the parser reads from. +//! +//! The tokenizer walks the input once and emits [`Token`] values. It mirrors +//! the CSS syntax grammar for identifiers, hashes, strings, numbers, comments, +//! whitespace, and single-character delimiters. Escapes (`\HHHHHH` unicode +//! escapes and `\X` simple escapes) are decoded as the tokens are produced. + +use alloc::string::String; +use alloc::vec::Vec; +use core::fmt; + +use crate::error::SelectorError; + +/// The kind of a token. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenType { + /// An identifier. + Ident, + /// A hash, such as `#foo`. The value drops the leading `#`. + Hash, + /// A quoted string. The value drops the quotes and decodes escapes. + String, + /// A run of whitespace, collapsed to a single space value. + S, + /// A number kept as its raw text, such as `-3.7`. + Number, + /// A single delimiter character that did not start another token. + Delim, + /// End of input. + Eof, +} + +impl TokenType { + /// The short name used in token `repr` output and parser error messages. + pub(crate) fn name(self) -> &'static str { + match self { + TokenType::Ident => "IDENT", + TokenType::Hash => "HASH", + TokenType::String => "STRING", + TokenType::S => "S", + TokenType::Number => "NUMBER", + TokenType::Delim => "DELIM", + TokenType::Eof => "EOF", + } + } +} + +/// A single token with its source position. +/// +/// Equality through [`Token::matches`] compares only the type and value. The +/// position is carried for error messages and the `:scope` placement guard but +/// is ignored when matching against expected `(type, value)` pairs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Token { + /// The token kind. + pub ty: TokenType, + /// The decoded value. `None` only for the end-of-input token. + pub value: Option<String>, + /// The zero-based character index where the token starts. + pub pos: usize, +} + +impl Token { + /// Build a token with a value. + pub fn new(ty: TokenType, value: impl Into<String>, pos: usize) -> Token { + Token { + ty, + value: Some(value.into()), + pos, + } + } + + /// Build the end-of-input token. + pub fn eof(pos: usize) -> Token { + Token { + ty: TokenType::Eof, + value: None, + pos, + } + } + + /// The decoded value as a string slice. Empty for the EOF token. + pub fn value_str(&self) -> &str { + self.value.as_deref().unwrap_or("") + } + + /// True when this token is a delimiter whose value is in `values`. + pub fn is_delim(&self, values: &[&str]) -> bool { + self.ty == TokenType::Delim && values.contains(&self.value_str()) + } + + /// True when type and value match the given pair. Position is ignored. + pub fn matches(&self, ty: TokenType, value: &str) -> bool { + self.ty == ty && self.value_str() == value + } + + /// The CSS serialization of the token value, used by `canonical()`. + /// + /// Strings are rendered with Python-style `repr`. Every other type returns + /// its raw value. + pub fn to_css(&self) -> String { + if self.ty == TokenType::String { + crate::util::py_repr(self.value_str()) + } else { + String::from(self.value_str()) + } + } +} + +impl fmt::Display for Token { + /// Render the token the way Python `repr(Token)` does. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.ty == TokenType::Eof { + write!(f, "<EOF at {}>", self.pos) + } else { + write!( + f, + "<{} '{}' at {}>", + self.ty.name(), + self.value_str(), + self.pos + ) + } + } +} + +/// Test whether a character is CSS whitespace. +fn is_ws(c: char) -> bool { + matches!(c, ' ' | '\t' | '\r' | '\n' | '\u{0c}') +} + +/// Test whether a character can start an identifier (`nmstart` minus escapes). +fn is_nmstart(c: char) -> bool { + c == '_' || c.is_ascii_alphabetic() || (c as u32) > 0x7F +} + +/// Test whether a character can continue an identifier (`nmchar` minus escapes). +fn is_nmchar(c: char) -> bool { + c == '_' || c == '-' || c.is_ascii_alphanumeric() || (c as u32) > 0x7F +} + +/// Tokenize a selector string into a token vector ending in an EOF token. +/// +/// Returns a syntax error only for an unterminated or malformed string. Every +/// other byte folds into some token. +pub fn tokenize(s: &str) -> Result<Vec<Token>, SelectorError> { + let chars: Vec<char> = s.chars().collect(); + let len = chars.len(); + let mut pos = 0usize; + let mut tokens = Vec::new(); + + while pos < len { + let c = chars[pos]; + + // 1. Whitespace. + if is_ws(c) { + let start = pos; + while pos < len && is_ws(chars[pos]) { + pos += 1; + } + tokens.push(Token::new(TokenType::S, " ", start)); + continue; + } + + // 2. Identifier. A leading '-' is allowed when followed by an nmstart + // character or an escape. + if let Some(end) = match_ident(&chars, pos) { + let raw: String = chars[pos..end].iter().collect(); + let value = unescape_ident(&raw); + tokens.push(Token::new(TokenType::Ident, value, pos)); + pos = end; + continue; + } + + // 3. Hash. + if c == '#' { + if let Some(end) = match_hash(&chars, pos) { + let raw: String = chars[pos + 1..end].iter().collect(); + let value = unescape_ident(&raw); + tokens.push(Token::new(TokenType::Hash, value, pos)); + pos = end; + continue; + } + } + + // 4. String. + if c == '\'' || c == '"' { + let quote = c; + let end = match_string_body(&chars, pos + 1, quote); + if end == len { + return Err(SelectorError::Syntax(alloc::format!( + "Unclosed string at {pos}" + ))); + } + if chars[end] != quote { + return Err(SelectorError::Syntax(alloc::format!( + "Invalid string at {pos}" + ))); + } + let raw: String = chars[pos + 1..end].iter().collect(); + let value = unescape_string(&raw); + tokens.push(Token::new(TokenType::String, value, pos)); + pos = end + 1; + continue; + } + + // 5. Number. + if let Some(end) = match_number(&chars, pos) { + let raw: String = chars[pos..end].iter().collect(); + tokens.push(Token::new(TokenType::Number, raw, pos)); + pos = end; + continue; + } + + // 6. Comment. An unterminated comment consumes to end of input. + if c == '/' && pos + 1 < len && chars[pos + 1] == '*' { + let mut search = pos + 2; + let mut found = None; + while search + 1 < len { + if chars[search] == '*' && chars[search + 1] == '/' { + found = Some(search + 2); + break; + } + search += 1; + } + pos = found.unwrap_or(len); + continue; + } + + // 7. Otherwise a single-character delimiter. + tokens.push(Token::new(TokenType::Delim, c, pos)); + pos += 1; + } + + tokens.push(Token::eof(len)); + Ok(tokens) +} + +/// Length of an escape sequence starting at `chars[i]` (where `chars[i]` is the +/// backslash), or `None` when the backslash does not begin a valid escape. +/// +/// A unicode escape is a backslash, one to six hex digits, and an optional +/// trailing whitespace character (with `\r\n` counted as one). A simple escape +/// is a backslash and one character that is not a newline or hex digit. +fn escape_len(chars: &[char], i: usize) -> Option<usize> { + if chars.get(i) != Some(&'\\') { + return None; + } + let after = i + 1; + let next = chars.get(after)?; + if next.is_ascii_hexdigit() { + let mut j = after; + let mut count = 0; + while j < chars.len() && count < 6 && chars[j].is_ascii_hexdigit() { + j += 1; + count += 1; + } + // Optional single trailing whitespace, with \r\n consumed as a pair. + if j + 1 < chars.len() && chars[j] == '\r' && chars[j + 1] == '\n' { + j += 2; + } else if j < chars.len() && is_ws(chars[j]) { + j += 1; + } + Some(j - i) + } else if matches!(next, '\n' | '\r' | '\u{0c}') { + // Not a valid simple escape: backslash followed by a newline. + None + } else { + // Simple escape consumes the backslash and one character. + Some(2) + } +} + +/// Match an identifier starting at `pos`. Returns the end index, exclusive. +fn match_ident(chars: &[char], pos: usize) -> Option<usize> { + let len = chars.len(); + let mut i = pos; + if i < len && chars[i] == '-' { + i += 1; + } + // First piece must be an nmstart or an escape. + if i < len && is_nmstart(chars[i]) { + i += 1; + } else { + let step = escape_len(chars, i)?; + i += step; + } + // Remaining nmchar pieces. + loop { + if i < len && is_nmchar(chars[i]) { + i += 1; + } else if let Some(step) = escape_len(chars, i) { + i += step; + } else { + break; + } + } + Some(i) +} + +/// Match a hash starting at `pos` (the `#`). Returns the end index, exclusive. +fn match_hash(chars: &[char], pos: usize) -> Option<usize> { + let len = chars.len(); + let mut i = pos + 1; + let start = i; + loop { + if i < len && is_nmchar(chars[i]) { + i += 1; + } else if let Some(step) = escape_len(chars, i) { + i += step; + } else { + break; + } + } + if i == start { + None + } else { + Some(i) + } +} + +/// Match a number starting at `pos`. Returns the end index, exclusive. +/// +/// The pattern is an optional sign, then either digits, a dot, and digits, or a +/// plain run of digits. +fn match_number(chars: &[char], pos: usize) -> Option<usize> { + let len = chars.len(); + let mut i = pos; + if i < len && (chars[i] == '+' || chars[i] == '-') { + i += 1; + } + let int_start = i; + while i < len && chars[i].is_ascii_digit() { + i += 1; + } + let had_int = i > int_start; + if i < len && chars[i] == '.' { + let dot = i; + i += 1; + let frac_start = i; + while i < len && chars[i].is_ascii_digit() { + i += 1; + } + if i > frac_start { + return Some(i); + } + // A dot with no following digits is only valid if integer digits ran. + i = dot; + } + if had_int { + Some(i) + } else { + None + } +} + +/// Find where a string body ends starting at `pos` (just past the open quote). +/// +/// Returns the index of the closing quote, or the input length when the string +/// runs to end of input. The body allows escapes, including line-continuation +/// escapes, and forbids raw newlines. +fn match_string_body(chars: &[char], pos: usize, quote: char) -> usize { + let len = chars.len(); + let mut i = pos; + while i < len { + let c = chars[i]; + if c == quote { + return i; + } + if matches!(c, '\n' | '\r' | '\u{0c}') { + return i; + } + if c == '\\' { + // Line continuation: backslash then a newline form. + if i + 1 < len { + if chars[i + 1] == '\r' && i + 2 < len && chars[i + 2] == '\n' { + i += 3; + continue; + } + if matches!(chars[i + 1], '\n' | '\r' | '\u{0c}') { + i += 2; + continue; + } + } + if let Some(step) = escape_len(chars, i) { + i += step; + continue; + } + // A trailing lone backslash. + i += 1; + continue; + } + i += 1; + } + len +} + +/// Decode the unicode and simple escapes in an identifier or hash value. +pub fn unescape_ident(value: &str) -> String { + let chars: Vec<char> = value.chars().collect(); + decode_escapes(&chars, false) +} + +/// Decode the escapes in a string value, including line continuations. +fn unescape_string(value: &str) -> String { + let chars: Vec<char> = value.chars().collect(); + decode_escapes(&chars, true) +} + +/// Shared escape decoder. +/// +/// When `strip_newlines` is set, a backslash before a newline form is dropped +/// (line continuation). A unicode escape maps to its code point. Two ranges map +/// to U+FFFD instead: code points above U+10FFFF, and the surrogate range +/// U+D800 through U+DFFF. A Rust string cannot hold a surrogate, so the decoder +/// folds it to the replacement character. Simple escapes drop the backslash. +fn decode_escapes(chars: &[char], strip_newlines: bool) -> String { + let len = chars.len(); + let mut out = String::with_capacity(len); + let mut i = 0; + while i < len { + if chars[i] != '\\' { + out.push(chars[i]); + i += 1; + continue; + } + let after = i + 1; + if after >= len { + out.push('\\'); + i += 1; + continue; + } + let next = chars[after]; + if strip_newlines { + if next == '\r' && after + 1 < len && chars[after + 1] == '\n' { + i = after + 2; + continue; + } + if matches!(next, '\n' | '\r' | '\u{0c}') { + i = after + 1; + continue; + } + } + if next.is_ascii_hexdigit() { + let mut j = after; + let mut count = 0; + let mut cp: u32 = 0; + while j < len && count < 6 && chars[j].is_ascii_hexdigit() { + cp = cp * 16 + + chars[j] + .to_digit(16) + .expect("hex digit checked by the loop guard"); + j += 1; + count += 1; + } + // Eat one optional trailing whitespace, with \r\n as a pair. + if j + 1 < len && chars[j] == '\r' && chars[j + 1] == '\n' { + j += 2; + } else if j < len && is_ws(chars[j]) { + j += 1; + } + // Above the Unicode maximum folds to U+FFFD. A surrogate code point + // also folds here, since `char::from_u32` rejects the surrogate + // range and a Rust string cannot store one. + let decoded = if cp > 0x10FFFF { + '\u{FFFD}' + } else { + char::from_u32(cp).unwrap_or('\u{FFFD}') + }; + out.push(decoded); + i = j; + } else { + // Simple escape: drop the backslash, keep the character. + out.push(next); + i = after + 1; + } + } + out +} diff --git a/browser/vendor/cssselect/src/util.rs b/browser/vendor/cssselect/src/util.rs new file mode 100644 index 000000000..a7fcbeb65 --- /dev/null +++ b/browser/vendor/cssselect/src/util.rs @@ -0,0 +1,99 @@ +//! Python-style string repr used by canonical serialization. + +use alloc::string::String; +use core::fmt::Write as _; + +/// Render a string the way CPython `repr()` does. +/// +/// The canonical form of a selector embeds string tokens through this function, +/// so the output must match CPython byte for byte. The rules: +/// +/// - Prefer single quotes. Switch to double quotes only when the string holds a +/// single quote but no double quote. +/// - Escape the backslash and the active quote. +/// - Escape `\t`, `\n`, `\r` with their short forms. +/// - Escape other non-printable code points with `\xNN`, `\uNNNN`, or +/// `\UNNNNNNNN` depending on width. +/// +/// "Printable" follows Python's `str.isprintable`: a character is printable +/// unless it sits in an "Other" or "Separator" Unicode category, with the space +/// U+0020 kept as printable. +pub(crate) fn py_repr(s: &str) -> String { + let has_single = s.contains('\''); + let has_double = s.contains('"'); + let quote = if has_single && !has_double { '"' } else { '\'' }; + + let mut out = String::with_capacity(s.len() + 2); + out.push(quote); + for ch in s.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '\t' => out.push_str("\\t"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + c if c == quote => { + out.push('\\'); + out.push(c); + } + c if is_py_printable(c) => out.push(c), + c => { + let cp = c as u32; + if cp <= 0xFF { + let _ = write!(out, "\\x{cp:02x}"); + } else if cp <= 0xFFFF { + let _ = write!(out, "\\u{cp:04x}"); + } else { + let _ = write!(out, "\\U{cp:08x}"); + } + } + } + } + out.push(quote); + out +} + +/// Match CPython `str.isprintable` for a single character. +/// +/// Printable means not in an "Other" (C*) or "Separator" (Z*) Unicode category. +/// The ASCII space U+0020 is the one separator Python still treats as +/// printable. +fn is_py_printable(c: char) -> bool { + if c == ' ' { + return true; + } + !is_other_or_separator(c) +} + +/// True when the character sits in a Unicode "Other" or "Separator" category. +/// +/// Python excludes Cc, Cf, Cs, Co, Cn, Zl, Zp, and Zs from `isprintable`. This +/// crate carries no Unicode database, so the check covers the control and +/// separator ranges that appear in CSS selector strings. Characters outside +/// these ranges count as printable, which matches Python for every input the +/// test suite and real selectors produce. +fn is_other_or_separator(c: char) -> bool { + let cp = c as u32; + // C0 and C1 control characters (Cc). + if cp <= 0x1F || (0x7F..=0x9F).contains(&cp) { + return true; + } + matches!( + cp, + // Separators (Zs, Zl, Zp) and common format or other characters (Cf, Cn). + 0x00A0 // no-break space (Zs) + | 0x00AD // soft hyphen (Cf) + | 0x0600..=0x0605 + | 0x061C + | 0x06DD + | 0x070F + | 0x1680 // ogham space mark (Zs) + | 0x180E // mongolian vowel separator + | 0x2000..=0x200F // spaces (Zs) and Cf marks + | 0x2028..=0x202F // line and paragraph separators and Cf marks + | 0x205F..=0x2064 // medium math space (Zs) and Cf marks + | 0x206A..=0x206F + | 0x3000 // ideographic space (Zs) + | 0xFEFF // zero width no-break space (Cf) + | 0xFFF9..=0xFFFB + ) +} diff --git a/browser/vendor/cssselect/src/xpath.rs b/browser/vendor/cssselect/src/xpath.rs new file mode 100644 index 000000000..ac98af1d3 --- /dev/null +++ b/browser/vendor/cssselect/src/xpath.rs @@ -0,0 +1,1072 @@ +//! XPath string builder and the translators. +//! +//! [`XpathExpr`] assembles an XPath 1.0 expression from a path, an element +//! test, and a condition. The translation engine walks a parsed [`Tree`] and +//! produces one expression per selector. [`GenericTranslator`] targets generic +//! XML. The HTML variant lives in [`crate::html`] and reuses this engine with a +//! different [`Config`]. + +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use crate::error::SelectorError; +use crate::parser::{parse, parse_series, PseudoElement, Selector, Tree}; +use crate::tokenizer::TokenType; + +/// An XPath 1.0 expression under construction. +/// +/// The string form is `path + element`, plus `[condition]` when a condition is +/// present. Conditions chain through [`XpathExpr::add_condition`], which wraps +/// each side in parentheses. +/// +/// The parts are private so the type stays consistent. The condition never +/// carries its own brackets, and the path and element stay well formed. Build +/// and read the expression through the methods. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct XpathExpr { + /// The location path prefix. + path: String, + /// The element or node test. + element: String, + /// The predicate condition, without the surrounding brackets. + condition: String, +} + +impl XpathExpr { + /// Build an expression from its parts. + pub fn new(path: &str, element: &str, condition: &str) -> XpathExpr { + XpathExpr { + path: path.to_string(), + element: element.to_string(), + condition: condition.to_string(), + } + } + + /// Build an expression with only the element test set. + fn element(element: &str) -> XpathExpr { + XpathExpr::new("", element, "") + } + + /// The string form: `path + element`, plus `[condition]` when present. + pub fn to_xpath(&self) -> String { + let mut out = format!("{}{}", self.path, self.element); + if !self.condition.is_empty() { + out.push('['); + out.push_str(&self.condition); + out.push(']'); + } + out + } + + /// Add a predicate condition, joined with `and` by default. + /// + /// When a condition already exists, both sides are wrapped in parentheses. + pub fn add_condition(&mut self, condition: &str, conjunction: &str) -> &mut Self { + if self.condition.is_empty() { + self.condition = condition.to_string(); + } else { + self.condition = format!("({}) {} ({})", self.condition, conjunction, condition); + } + self + } + + /// Add an `and`-joined condition. Shorthand for the common case. + fn and_condition(&mut self, condition: &str) -> &mut Self { + self.add_condition(condition, "and") + } + + /// Fold a non-universal element test into a `name()` condition. + fn add_name_test(&mut self) { + if self.element == "*" { + return; + } + let mut parts = self.element.splitn(2, ':'); + let prefix = parts.next().unwrap_or(""); + let local = parts.next(); + let safe = + is_safe_name(prefix) && local.is_none_or(|value| value == "*" || is_safe_name(value)); + let cond = if safe { + format!("self::{}", self.element) + } else { + format!("name() = {}", xpath_literal(&self.element)) + }; + self.and_condition(&cond); + self.element = "*".to_string(); + } + + /// Join this expression to another with a combiner string. + /// + /// Shorthand for [`XpathExpr::join_full`] with no closing combiner and no + /// inner condition folding. + pub fn join(&mut self, combiner: &str, other: &XpathExpr) -> &mut Self { + self.join_full(combiner, other, None, false) + } + + /// Join this expression to another with full control. + /// + /// When `has_inner_condition` is set, the other expression's condition is + /// folded into the element test rather than kept separate. The optional + /// closing combiner is appended to the element test. + pub fn join_full( + &mut self, + combiner: &str, + other: &XpathExpr, + closing_combiner: Option<&str>, + has_inner_condition: bool, + ) -> &mut Self { + let mut path = format!("{}{}", self.to_xpath(), combiner); + if other.path != "*/" { + path.push_str(&other.path); + } + self.path = path; + if !has_inner_condition { + self.element = match closing_combiner { + Some(cc) => format!("{}{}", other.element, cc), + None => other.element.clone(), + }; + self.condition = other.condition.clone(); + } else { + self.element = other.element.clone(); + if !other.condition.is_empty() { + self.element = format!("{}[{}]", self.element, other.condition); + } + if let Some(cc) = closing_combiner { + self.element.push_str(cc); + } + } + self + } +} + +/// Render a value as an XPath 1.0 string literal. +/// +/// XPath 1.0 has no string escapes, so a value with both quote kinds is built +/// with `concat(...)`. +pub fn xpath_literal(s: &str) -> String { + if !s.contains('\'') { + format!("'{s}'") + } else if !s.contains('"') { + format!("\"{s}\"") + } else { + let parts = split_at_single_quotes(s); + let mut quoted: Vec<String> = Vec::new(); + for part in parts { + if part.is_empty() { + continue; + } + if part.contains('\'') { + quoted.push(format!("\"{part}\"")); + } else { + quoted.push(format!("'{part}'")); + } + } + format!("concat({})", quoted.join(",")) + } +} + +/// Split a string keeping runs of single quotes as separate parts. +/// +/// This mirrors Python `re.split("('+)", s)`, where the capturing group keeps +/// the separator runs. Empty parts can appear and are dropped by the caller. +fn split_at_single_quotes(s: &str) -> Vec<String> { + let mut parts: Vec<String> = Vec::new(); + let mut current = String::new(); + let mut chars = s.chars().peekable(); + while let Some(&c) = chars.peek() { + if c == '\'' { + parts.push(core::mem::take(&mut current)); + let mut run = String::new(); + while let Some(&c2) = chars.peek() { + if c2 == '\'' { + run.push('\''); + chars.next(); + } else { + break; + } + } + parts.push(run); + } else { + current.push(c); + chars.next(); + } + } + parts.push(current); + parts +} + +/// True when a name is safe to drop straight into an XPath name position. +fn is_safe_name(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')) +} + +/// True when a value is non-empty and holds no whitespace. +fn is_non_whitespace(value: &str) -> bool { + !value.is_empty() + && !value + .chars() + .any(|c| matches!(c, ' ' | '\t' | '\r' | '\n' | '\u{0c}')) +} + +/// Case-folding and document-language settings for a translator. +/// +/// Build one with [`Config::generic`] or [`Config::html`]. The fields are +/// crate-internal so a caller cannot mix settings into an invalid state, such +/// as the HTML pseudo-classes paired with the XML `lang` attribute. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Config { + /// The attribute name used by `:lang()`. + pub(crate) lang_attribute: &'static str, + /// Whether to fold element names to lower case. + pub(crate) lower_case_element_names: bool, + /// Whether to fold attribute names to lower case. + pub(crate) lower_case_attribute_names: bool, + /// Whether to fold attribute values to lower case. + pub(crate) lower_case_attribute_values: bool, + /// Whether to apply the HTML-specific pseudo-class implementations. + pub(crate) html: bool, +} + +impl Config { + /// The generic XML configuration: fully case sensitive, `xml:lang`. + pub fn generic() -> Config { + Config { + lang_attribute: "xml:lang", + lower_case_element_names: false, + lower_case_attribute_names: false, + lower_case_attribute_values: false, + html: false, + } + } + + /// The HTML configuration. With `xhtml`, names stay case sensitive. + pub fn html(xhtml: bool) -> Config { + Config { + lang_attribute: "lang", + lower_case_element_names: !xhtml, + lower_case_attribute_names: !xhtml, + lower_case_attribute_values: false, + html: true, + } + } +} + +/// Translate a parsed group of selectors to an XPath string. +/// +/// Per-selector results are joined with `" | "`. Pseudo-elements are translated +/// here, which raises an expression error in the built-in translators. +pub(crate) fn css_to_xpath(cfg: &Config, css: &str, prefix: &str) -> Result<String, SelectorError> { + let selectors = parse(css)?; + let mut parts: Vec<String> = Vec::with_capacity(selectors.len()); + for selector in &selectors { + parts.push(selector_to_xpath( + cfg, + selector, + prefix, + PseudoElements::Translate, + )?); + } + Ok(parts.join(" | ")) +} + +/// How a translator handles a selector's pseudo-element. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PseudoElements { + /// Translate the pseudo-element. The built-in translators reject it with an + /// expression error, since XPath has no equivalent. + Translate, + /// Ignore the pseudo-element and translate the rest of the selector. + Ignore, +} + +/// Translate a single parsed selector to an XPath string. +/// +/// With [`PseudoElements::Ignore`], the selector's pseudo-element is dropped. +pub(crate) fn selector_to_xpath( + cfg: &Config, + selector: &Selector, + prefix: &str, + pseudo_elements: PseudoElements, +) -> Result<String, SelectorError> { + let mut xpath = xpath_tree(cfg, &selector.parsed_tree)?; + if pseudo_elements == PseudoElements::Translate { + if let Some(pe) = &selector.pseudo_element { + xpath = xpath_pseudo_element(xpath, pe)?; + } + } + Ok(format!("{}{}", prefix, xpath.to_xpath())) +} + +/// Scrapling's two Parsel-compatible pseudo-elements. +fn xpath_pseudo_element( + xpath: XpathExpr, + pseudo_element: &PseudoElement, +) -> Result<XpathExpr, SelectorError> { + let mut path = xpath.to_xpath(); + match pseudo_element { + PseudoElement::Ident(name) if name == "text" => { + if path == "*" { + path = "text()".to_string(); + } else if path.ends_with("::*/*") { + path.truncate(path.len() - 3); + path.push_str("text()"); + } else { + path.push_str("/text()"); + } + } + PseudoElement::Functional(function) + if function.name == "attr" + && function.arguments.len() == 1 + && matches!( + function.arguments[0].ty, + TokenType::String | TokenType::Ident + ) => + { + if path.ends_with("::*/*") { + path.truncate(path.len() - 2); + } + path.push_str("/@"); + path.push_str(function.arguments[0].value_str()); + } + PseudoElement::Functional(function) if function.name == "attr" => { + return Err(SelectorError::Expression(format!( + "Expected a single string or ident for ::attr(), got {:?}", + function.arguments + ))); + } + PseudoElement::Functional(function) => { + return Err(SelectorError::Expression(format!( + "The functional pseudo-element ::{}() is unknown", + function.name + ))); + } + PseudoElement::Ident(name) => { + return Err(SelectorError::Expression(format!( + "The pseudo-element ::{name} is unknown" + ))); + } + } + Ok(XpathExpr::new("", &path, "")) +} + +/// Dispatch a parsed tree node to its translator. +fn xpath_tree(cfg: &Config, tree: &Tree) -> Result<XpathExpr, SelectorError> { + match tree { + Tree::Element { namespace, element } => Ok(xpath_element(cfg, namespace, element)), + Tree::Hash { selector, id } => { + let mut xpath = xpath_tree(cfg, selector)?; + xpath.and_condition(&format!("@id = {}", xpath_literal(id))); + Ok(xpath) + } + Tree::Class { + selector, + class_name, + } => { + let mut xpath = xpath_tree(cfg, selector)?; + attrib_includes(&mut xpath, "@class", Some(class_name)); + Ok(xpath) + } + Tree::Attrib { .. } => xpath_attrib(cfg, tree), + Tree::Pseudo { selector, ident } => { + let xpath = xpath_tree(cfg, selector)?; + xpath_pseudo(cfg, xpath, ident) + } + Tree::Function { + selector, + name, + arguments, + } => { + let xpath = xpath_tree(cfg, selector)?; + xpath_function(cfg, xpath, name, arguments) + } + Tree::Negation { + selector, + subselector, + } => { + let mut xpath = xpath_tree(cfg, selector)?; + let mut sub = xpath_tree(cfg, subselector)?; + sub.add_name_test(); + if !sub.condition.is_empty() { + xpath.and_condition(&format!("not({})", sub.condition)); + } else { + xpath.and_condition("0"); + } + Ok(xpath) + } + Tree::Relation { + selector, + combinator, + subselector, + } => { + let xpath = xpath_tree(cfg, selector)?; + let right = xpath_tree(cfg, &subselector.parsed_tree)?; + xpath_relation(xpath, combinator.value_str(), right) + } + Tree::Matching { + selector, + selector_list, + } + | Tree::SpecificityAdjustment { + selector, + selector_list, + } => { + let mut xpath = xpath_tree(cfg, selector)?; + let mut alternatives = Vec::new(); + for sel in selector_list { + let mut e = xpath_tree(cfg, sel)?; + e.add_name_test(); + alternatives.push(if e.condition.is_empty() { + "1".to_string() + } else { + e.condition + }); + } + if alternatives.is_empty() { + xpath.and_condition("0"); + } else if alternatives.len() == 1 { + xpath.and_condition(&alternatives[0]); + } else { + xpath.and_condition(&format!("({})", alternatives.join(") or ("))); + } + Ok(xpath) + } + Tree::Combined { + selector, + combinator, + subselector, + } => { + let left = xpath_tree(cfg, selector)?; + let right = xpath_tree(cfg, subselector)?; + xpath_combinator(combinator, left, right) + } + } +} + +/// Translate a type or universal selector. +fn xpath_element(cfg: &Config, namespace: &Option<String>, element: &Option<String>) -> XpathExpr { + let mut safe; + let mut name; + match element { + None => { + name = "*".to_string(); + safe = true; + } + Some(el) => { + name = el.clone(); + safe = is_safe_name(&name); + if cfg.lower_case_element_names { + name = name.to_ascii_lowercase(); + } + } + } + if let Some(ns) = namespace { + name = format!("{ns}:{name}"); + safe = safe && is_safe_name(ns); + } + let mut xpath = XpathExpr::element(&name); + if !safe { + xpath.add_name_test(); + } + xpath +} + +/// Translate an attribute selector. +fn xpath_attrib(cfg: &Config, tree: &Tree) -> Result<XpathExpr, SelectorError> { + let (selector, namespace, attrib, operator, value) = match tree { + Tree::Attrib { + selector, + namespace, + attrib, + operator, + value, + } => (selector, namespace, attrib, operator, value), + _ => unreachable!("xpath_attrib called on a non-Attrib tree"), + }; + + let mut name = if cfg.lower_case_attribute_names { + attrib.to_ascii_lowercase() + } else { + attrib.clone() + }; + let mut safe = is_safe_name(&name); + if let Some(ns) = namespace { + name = format!("{ns}:{name}"); + safe = safe && is_safe_name(ns); + } + let attrib_xpath = if safe { + format!("@{name}") + } else { + format!("attribute::*[name() = {}]", xpath_literal(&name)) + }; + + let value_str: Option<String> = match value { + None => None, + Some(token) => { + if cfg.lower_case_attribute_values { + Some(token.value_str().to_ascii_lowercase()) + } else { + Some(token.value_str().to_string()) + } + } + }; + + let mut xpath = xpath_tree(cfg, selector)?; + apply_attrib_operator(&mut xpath, operator, &attrib_xpath, value_str.as_deref())?; + Ok(xpath) +} + +/// Apply the named attribute operator to the expression. +fn apply_attrib_operator( + xpath: &mut XpathExpr, + operator: &str, + name: &str, + value: Option<&str>, +) -> Result<(), SelectorError> { + match operator { + "exists" => { + xpath.and_condition(name); + } + "=" => { + xpath.and_condition(&format!( + "{} = {}", + name, + xpath_literal(value.unwrap_or("")) + )); + } + "!=" => { + let v = value.unwrap_or(""); + if !v.is_empty() { + xpath.and_condition(&format!("not({name}) or {name} != {}", xpath_literal(v))); + } else { + xpath.and_condition(&format!("{name} != {}", xpath_literal(v))); + } + } + "~=" => attrib_includes(xpath, name, value), + "|=" => { + let v = value.unwrap_or(""); + let arg = xpath_literal(v); + let arg_dash = xpath_literal(&format!("{v}-")); + xpath.and_condition(&format!( + "{name} and ({name} = {arg} or starts-with({name}, {arg_dash}))" + )); + } + "^=" => { + let v = value.unwrap_or(""); + if !v.is_empty() { + xpath.and_condition(&format!( + "{name} and starts-with({name}, {})", + xpath_literal(v) + )); + } else { + xpath.and_condition("0"); + } + } + "$=" => { + let v = value.unwrap_or(""); + if !v.is_empty() { + let len = v.chars().count() as i64 - 1; + xpath.and_condition(&format!( + "{name} and substring({name}, string-length({name})-{len}) = {}", + xpath_literal(v) + )); + } else { + xpath.and_condition("0"); + } + } + "*=" => { + let v = value.unwrap_or(""); + if !v.is_empty() { + xpath.and_condition(&format!( + "{name} and contains({name}, {})", + xpath_literal(v) + )); + } else { + xpath.and_condition("0"); + } + } + other => { + return Err(SelectorError::Expression(format!( + "Unknown attribute operator: {other}" + ))); + } + } + Ok(()) +} + +/// The `~=` includes operator, shared by class selectors and `[a~=b]`. +fn attrib_includes(xpath: &mut XpathExpr, name: &str, value: Option<&str>) { + let v = value.unwrap_or(""); + if !v.is_empty() && is_non_whitespace(v) { + let arg = xpath_literal(&format!(" {v} ")); + xpath.and_condition(&format!( + "{name} and contains(concat(' ', normalize-space({name}), ' '), {arg})" + )); + } else { + xpath.and_condition("0"); + } +} + +/// Translate a combinator between a left and right expression. +fn xpath_combinator( + combinator: &str, + mut left: XpathExpr, + right: XpathExpr, +) -> Result<XpathExpr, SelectorError> { + match combinator { + " " => { + left.join_full("/descendant-or-self::*/", &right, None, false); + } + ">" => { + left.join_full("/", &right, None, false); + } + "+" => { + left.join_full("/following-sibling::", &right, None, false); + left.add_name_test(); + left.and_condition("position() = 1"); + } + "~" => { + left.join_full("/following-sibling::", &right, None, false); + } + other => { + return Err(SelectorError::Expression(format!( + "Unknown combinator: {other}" + ))); + } + } + Ok(left) +} + +/// Translate a `:has()` relation by its combinator. +fn xpath_relation( + mut left: XpathExpr, + combinator: &str, + mut right: XpathExpr, +) -> Result<XpathExpr, SelectorError> { + match combinator { + " " => { + left.join_full("[descendant::", &right, Some("]"), true); + } + ">" => { + left.join_full("[./", &right, Some("]"), true); + } + "+" => { + right.add_name_test(); + right.and_condition("position() = 1"); + left.and_condition(&format!( + "following-sibling::{}[{}]", + right.element, right.condition + )); + } + "~" => { + left.join_full("[following-sibling::", &right, Some("]"), true); + } + other => { + return Err(SelectorError::Expression(format!( + "Unknown combinator: {other}" + ))); + } + } + Ok(left) +} + +/// Translate a functional pseudo-class. +fn xpath_function( + cfg: &Config, + xpath: XpathExpr, + name: &str, + arguments: &[crate::tokenizer::Token], +) -> Result<XpathExpr, SelectorError> { + match name { + "nth-child" => nth_child(xpath, arguments, false, true), + "nth-last-child" => nth_child(xpath, arguments, true, true), + "nth-of-type" => { + if xpath.element == "*" { + return Err(SelectorError::Expression( + "*:nth-of-type() is not implemented".to_string(), + )); + } + nth_child(xpath, arguments, false, false) + } + "nth-last-of-type" => { + if xpath.element == "*" { + return Err(SelectorError::Expression( + "*:nth-of-type() is not implemented".to_string(), + )); + } + nth_child(xpath, arguments, true, false) + } + "contains" => contains_function(xpath, arguments), + "lang" => lang_function(cfg, xpath, arguments), + other => Err(SelectorError::Expression(format!( + "The pseudo-class :{other}() is unknown" + ))), + } +} + +/// The `:nth-*` core algorithm. +fn nth_child( + mut xpath: XpathExpr, + arguments: &[crate::tokenizer::Token], + last: bool, + add_name_test: bool, +) -> Result<XpathExpr, SelectorError> { + let invalid_series = + || SelectorError::Expression(format!("Invalid series: '{}'", repr_token_list(arguments))); + let (a, b) = match parse_series(arguments) { + Ok(pair) => pair, + Err(_) => return Err(invalid_series()), + }; + let b_min_1 = match b.checked_sub(1) { + Some(value) => value, + None if a == 1 => return Ok(xpath), + None if a <= 0 => { + xpath.and_condition("0"); + return Ok(xpath); + } + None => return Err(invalid_series()), + }; + if a == 1 && b_min_1 <= 0 { + return Ok(xpath); + } + if a < 0 && b_min_1 < 0 { + xpath.and_condition("0"); + return Ok(xpath); + } + let nodetest = if add_name_test { + "*".to_string() + } else { + xpath.element.clone() + }; + let siblings_count = if !last { + format!("count(preceding-sibling::{nodetest})") + } else { + format!("count(following-sibling::{nodetest})") + }; + if a == 0 { + xpath.and_condition(&format!("{siblings_count} = {b_min_1}")); + return Ok(xpath); + } + let mut expressions: Vec<String> = Vec::new(); + if a > 0 { + if b_min_1 > 0 { + expressions.push(format!("{siblings_count} >= {b_min_1}")); + } + } else { + expressions.push(format!("{siblings_count} <= {b_min_1}")); + } + let a_abs = match a.checked_abs() { + Some(value) => value, + None if b_min_1 < 0 => { + xpath.and_condition("0"); + return Ok(xpath); + } + None => { + xpath.and_condition(&format!("{siblings_count} = {b_min_1}")); + return Ok(xpath); + } + }; + if a_abs != 1 { + let mut left = siblings_count.clone(); + let b_neg = (-(b_min_1 as i128)).rem_euclid(a_abs as i128); + if b_neg != 0 { + left = format!("({left} +{b_neg})"); + } + expressions.push(format!("{left} mod {a} = 0")); + } + let condition = if expressions.len() > 1 { + expressions + .iter() + .map(|e| format!("({e})")) + .collect::<Vec<_>>() + .join(" and ") + } else { + expressions.join(" and ") + }; + xpath.and_condition(&condition); + Ok(xpath) +} + +/// Translate `:contains()`. +fn contains_function( + mut xpath: XpathExpr, + arguments: &[crate::tokenizer::Token], +) -> Result<XpathExpr, SelectorError> { + let types = arg_types(arguments); + if types != ["STRING"] && types != ["IDENT"] { + return Err(SelectorError::Expression(format!( + "Expected a single string or ident for :contains(), got {}", + repr_token_list(arguments) + ))); + } + let value = arguments[0].value_str(); + xpath.and_condition(&format!("contains(., {})", xpath_literal(value))); + Ok(xpath) +} + +/// Translate `:lang()` for the generic translator. +fn lang_function( + cfg: &Config, + mut xpath: XpathExpr, + arguments: &[crate::tokenizer::Token], +) -> Result<XpathExpr, SelectorError> { + let types = arg_types(arguments); + if types != ["STRING"] && types != ["IDENT"] { + return Err(SelectorError::Expression(format!( + "Expected a single string or ident for :lang(), got {}", + repr_token_list(arguments) + ))); + } + let value = arguments[0].value_str(); + if cfg.html { + let arg = xpath_literal(&format!("{}-", value.to_ascii_lowercase())); + xpath.and_condition(&format!( + "ancestor-or-self::*[@lang][1][starts-with(concat(translate(@{}, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '-'), {})]", + cfg.lang_attribute, arg + )); + } else { + xpath.and_condition(&format!("lang({})", xpath_literal(value))); + } + Ok(xpath) +} + +/// The argument-type names for a token slice. +fn arg_types(arguments: &[crate::tokenizer::Token]) -> Vec<&'static str> { + arguments.iter().map(|t| t.ty.name()).collect() +} + +/// Render a token list the way a list of token reprs prints: `[<..>, <..>]`. +/// +/// The expression errors echo the offending argument tokens, so the text has to +/// match the repr of the argument list character for character. +fn repr_token_list(arguments: &[crate::tokenizer::Token]) -> String { + let mut out = String::from("["); + for (i, t) in arguments.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + out.push_str(&format!("{t}")); + } + out.push(']'); + out +} + +/// Translate a simple pseudo-class. +fn xpath_pseudo( + cfg: &Config, + mut xpath: XpathExpr, + ident: &str, +) -> Result<XpathExpr, SelectorError> { + match ident { + "root" => { + xpath.and_condition("not(parent::*)"); + } + "scope" => { + xpath.and_condition("1"); + } + "first-child" => { + xpath.and_condition("count(preceding-sibling::*) = 0"); + } + "last-child" => { + xpath.and_condition("count(following-sibling::*) = 0"); + } + "first-of-type" => { + if xpath.element == "*" { + return Err(SelectorError::Expression( + "*:first-of-type is not implemented".to_string(), + )); + } + let cond = format!("count(preceding-sibling::{}) = 0", xpath.element); + xpath.and_condition(&cond); + } + "last-of-type" => { + if xpath.element == "*" { + return Err(SelectorError::Expression( + "*:last-of-type is not implemented".to_string(), + )); + } + let cond = format!("count(following-sibling::{}) = 0", xpath.element); + xpath.and_condition(&cond); + } + "only-child" => { + xpath.and_condition("count(parent::*/child::*) = 1"); + } + "only-of-type" => { + if xpath.element == "*" { + return Err(SelectorError::Expression( + "*:only-of-type is not implemented".to_string(), + )); + } + let cond = format!("count(parent::*/child::{}) = 1", xpath.element); + xpath.and_condition(&cond); + } + "empty" => { + xpath.and_condition("not(*) and not(string-length())"); + } + "link" => { + if cfg.html { + xpath.and_condition( + "@href and (name(.) = 'a' or name(.) = 'link' or name(.) = 'area')", + ); + } else { + xpath.and_condition("0"); + } + } + "checked" => { + if cfg.html { + xpath.and_condition( + "(@selected and name(.) = 'option') or (@checked and (name(.) = 'input' or name(.) = 'command')and (@type = 'checkbox' or @type = 'radio'))", + ); + } else { + xpath.and_condition("0"); + } + } + "disabled" => { + if cfg.html { + xpath.and_condition(HTML_DISABLED); + } else { + xpath.and_condition("0"); + } + } + "enabled" => { + if cfg.html { + xpath.and_condition(HTML_ENABLED); + } else { + xpath.and_condition("0"); + } + } + "visited" | "hover" | "active" | "focus" | "target" => { + xpath.and_condition("0"); + } + other => { + return Err(SelectorError::Expression(format!( + "The pseudo-class :{other} is unknown" + ))); + } + } + Ok(xpath) +} + +/// The `:disabled` condition for HTML, byte-for-byte with the document order. +const HTML_DISABLED: &str = " + ( + @disabled and + ( + (name(.) = 'input' and @type != 'hidden') or + name(.) = 'button' or + name(.) = 'select' or + name(.) = 'textarea' or + name(.) = 'command' or + name(.) = 'fieldset' or + name(.) = 'optgroup' or + name(.) = 'option' + ) + ) or ( + ( + (name(.) = 'input' and @type != 'hidden') or + name(.) = 'button' or + name(.) = 'select' or + name(.) = 'textarea' + ) + and ancestor::fieldset[@disabled] + ) + "; + +/// The `:enabled` condition for HTML, byte-for-byte with the document order. +const HTML_ENABLED: &str = " + ( + @href and ( + name(.) = 'a' or + name(.) = 'link' or + name(.) = 'area' + ) + ) or ( + ( + name(.) = 'command' or + name(.) = 'fieldset' or + name(.) = 'optgroup' + ) + and not(@disabled) + ) or ( + ( + (name(.) = 'input' and @type != 'hidden') or + name(.) = 'button' or + name(.) = 'select' or + name(.) = 'textarea' or + name(.) = 'keygen' + ) + and not (@disabled or ancestor::fieldset[@disabled]) + ) or ( + name(.) = 'option' and not( + @disabled or ancestor::optgroup[@disabled] + ) + ) + "; + +/// Translator that targets generic XML. Fully case sensitive. +#[derive(Debug, Clone)] +pub struct GenericTranslator { + config: Config, +} + +impl Default for GenericTranslator { + fn default() -> Self { + GenericTranslator::new() + } +} + +impl GenericTranslator { + /// Create a translator with default settings. + pub fn new() -> Self { + GenericTranslator { + config: Config::generic(), + } + } + + /// Translate a CSS group of selectors to an XPath string. + /// + /// The default prefix scopes selectors to the context node's subtree. Per + /// selector results join with `" | "`. + pub fn css_to_xpath(&self, css: &str) -> Result<String, SelectorError> { + css_to_xpath(&self.config, css, "descendant-or-self::") + } + + /// Translate a CSS group of selectors using an explicit prefix. + /// + /// An empty prefix yields an XPath with no leading axis. + pub fn css_to_xpath_with_prefix( + &self, + css: &str, + prefix: &str, + ) -> Result<String, SelectorError> { + css_to_xpath(&self.config, css, prefix) + } + + /// Translate a single parsed selector to an XPath string. + /// + /// The pseudo-element is ignored. The default prefix scopes the selector to + /// the context node's subtree. + pub fn selector_to_xpath(&self, selector: &Selector) -> Result<String, SelectorError> { + selector_to_xpath( + &self.config, + selector, + "descendant-or-self::", + PseudoElements::Ignore, + ) + } + + /// Translate a single parsed selector with an explicit prefix and + /// pseudo-element handling. + pub fn selector_to_xpath_with( + &self, + selector: &Selector, + prefix: &str, + pseudo_elements: PseudoElements, + ) -> Result<String, SelectorError> { + selector_to_xpath(&self.config, selector, prefix, pseudo_elements) + } +} diff --git a/browser/vendor/cssselect/tests/canonical.rs b/browser/vendor/cssselect/tests/canonical.rs new file mode 100644 index 000000000..b30ceb385 --- /dev/null +++ b/browser/vendor/cssselect/tests/canonical.rs @@ -0,0 +1,78 @@ +//! Canonical CSS serialization round-trips. + +use cssselect::parse; + +/// Parse `css` and assert its canonical form equals `expected`. +fn css2css(css: &str, expected: &str) { + let selectors = parse(css).unwrap(); + assert_eq!(selectors.len(), 1, "expected one selector for {css:?}"); + assert_eq!(selectors[0].canonical(), expected, "for {css:?}"); +} + +#[test] +fn normalizations() { + css2css("*", "*"); + css2css(" foo", "foo"); + css2css("Foo", "Foo"); + css2css(":empty ", ":empty"); + css2css(":before", "::before"); + css2css(":beFOre", "::before"); + css2css("*:before", "::before"); + css2css(":nth-child(2)", ":nth-child(2)"); + css2css(".bar", ".bar"); + css2css("[baz]", "[baz]"); + css2css("[baz=\"4\"]", "[baz='4']"); + css2css("[baz^=\"4\"]", "[baz^='4']"); + css2css("[ns|attr='4']", "[ns|attr='4']"); + css2css("#lipsum", "#lipsum"); +} + +#[test] +fn logical_pseudos() { + css2css(":not(*)", ":not(*)"); + css2css(":not(foo)", ":not(foo)"); + css2css(":not(*.foo)", ":not(.foo)"); + css2css(":not(*[foo])", ":not([foo])"); + css2css(":not(:empty)", ":not(:empty)"); + css2css(":not(#foo)", ":not(#foo)"); + css2css(":has(*)", ":has(*)"); + css2css(":has(foo)", ":has(foo)"); + css2css(":has(*.foo)", ":has(.foo)"); + css2css(":is(#bar, .foo)", ":is(#bar, .foo)"); + css2css(":is(a,b)", ":is(a, b)"); + css2css(":is(:focused, :visited)", ":is(:focused, :visited)"); + css2css(":where(:focused, :visited)", ":where(:focused, :visited)"); +} + +#[test] +fn pseudo_elements_and_combinators() { + css2css("foo:empty", "foo:empty"); + css2css("foo::before", "foo::before"); + css2css("foo:empty::before", "foo:empty::before"); + css2css("::name(arg + \"val\" - 3)", "::name(arg+'val'-3)"); + css2css( + "#lorem + foo#ipsum:first-child > bar::first-line", + "#lorem + foo#ipsum:first-child > bar::first-line", + ); + css2css("foo > *", "foo > *"); +} + +#[test] +fn string_value_escaping() { + // The value repr emits short escapes for tab and newline. + css2css("[a=\"\\9 tab\"]", "[a='\\ttab']"); + css2css("[a=\"line\\a break\"]", "[a='line\\nbreak']"); + // The quote choice flips to double quotes when the value holds a single + // quote and no double quote. + css2css("[a='it\\'s']", "[a=\"it's\"]"); + css2css("[a=\"b\\\"c\"]", "[a='b\"c']"); +} + +#[test] +fn matching_aliases_and_collapses() { + // `:matches` canonicalizes to `:is`. + css2css(":matches(a, b)", ":is(a, b)"); + // A universal-only `:where` collapses its argument to empty. + css2css(":where(*)", ":where()"); + css2css(":is(div, .a, #b)", ":is(div, .a, #b)"); +} diff --git a/browser/vendor/cssselect/tests/fixtures/html_ids.html b/browser/vendor/cssselect/tests/fixtures/html_ids.html new file mode 100644 index 000000000..155eb5648 --- /dev/null +++ b/browser/vendor/cssselect/tests/fixtures/html_ids.html @@ -0,0 +1,49 @@ + +<html id="html"><head> + <link id="link-href" href="foo" /> + <link id="link-nohref" /> +</head><body> +<div id="outer-div"> + <a id="name-anchor" name="foo"></a> + <a id="tag-anchor" rel="tag" href="http://localhost/foo">link</a> + <a id="nofollow-anchor" rel="nofollow" href="https://example.org"> + link</a> + <ol id="first-ol" class="a b c"> + <li id="first-li">content</li> + <li id="second-li" lang="En-us"> + <div id="li-div"> + </div> + </li> + <li id="third-li" class="ab c"></li> + <li id="fourth-li" class="ab +c"></li> + <li id="fifth-li"></li> + <li id="sixth-li"></li> + <li id="seventh-li"> </li> + </ol> + <p id="paragraph"> + <b id="p-b">hi</b> <em id="p-em">there</em> + <b id="p-b2">guy</b> + <input type="checkbox" id="checkbox-unchecked" /> + <input type="checkbox" id="checkbox-disabled" disabled="" /> + <input type="text" id="text-checked" checked="checked" /> + <input type="hidden" /> + <input type="hidden" disabled="disabled" /> + <input type="checkbox" id="checkbox-checked" checked="checked" /> + <input type="checkbox" id="checkbox-disabled-checked" + disabled="disabled" checked="checked" /> + <fieldset id="fieldset" disabled="disabled"> + <input type="checkbox" id="checkbox-fieldset-disabled" /> + <input type="hidden" /> + </fieldset> + </p> + <ol id="second-ol"> + </ol> + <map name="dummymap"> + <area shape="circle" coords="200,250,25" href="foo.html" id="area-href" /> + <area shape="default" id="area-nohref" /> + </map> +</div> +<div id="foobar-div" foobar="ab bc +cde"><span id="foobar-span"></span></div> +</body></html> diff --git a/browser/vendor/cssselect/tests/fixtures/operator_precedence.xml b/browser/vendor/cssselect/tests/fixtures/operator_precedence.xml new file mode 100644 index 000000000..630820b13 --- /dev/null +++ b/browser/vendor/cssselect/tests/fixtures/operator_precedence.xml @@ -0,0 +1,6 @@ + +<html> + <a id="first"></a> + <a id="second" href="#"></a> + <a id="third" href="#"></a> +</html> diff --git a/browser/vendor/cssselect/tests/fixtures/shakespeare.html b/browser/vendor/cssselect/tests/fixtures/shakespeare.html new file mode 100644 index 000000000..bec4d97a9 --- /dev/null +++ b/browser/vendor/cssselect/tests/fixtures/shakespeare.html @@ -0,0 +1,309 @@ + +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" + "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en" debug="true"> +<head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> +</head> +<body> + <div id="test"> + <div class="dialog"> + <h2>As You Like It</h2> + <div id="playwright"> + by William Shakespeare + </div> + <div class="dialog scene thirdClass" id="scene1"> + <h3>ACT I, SCENE III. A room in the palace.</h3> + <div class="dialog"> + <div class="direction">Enter CELIA and ROSALIND</div> + </div> + <div id="speech1" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.1">Why, cousin! why, Rosalind! Cupid have mercy! not a word?</div> + </div> + <div id="speech2" class="character">ROSALIND</div> + <div class="dialog"> + <div id="scene1.3.2">Not one to throw at a dog.</div> + </div> + <div id="speech3" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.3">No, thy words are too precious to be cast away upon</div> + <div id="scene1.3.4">curs; throw some of them at me; come, lame me with reasons.</div> + </div> + <div id="speech4" class="character">ROSALIND</div> + <div id="speech5" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.8">But is all this for your father?</div> + </div> + <div class="dialog"> + <div id="scene1.3.5">Then there were two cousins laid up; when the one</div> + <div id="scene1.3.6">should be lamed with reasons and the other mad</div> + <div id="scene1.3.7">without any.</div> + </div> + <div id="speech6" class="character">ROSALIND</div> + <div class="dialog"> + <div id="scene1.3.9">No, some of it is for my child's father. O, how</div> + <div id="scene1.3.10">full of briers is this working-day world!</div> + </div> + <div id="speech7" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.11">They are but burs, cousin, thrown upon thee in</div> + <div id="scene1.3.12">holiday foolery: if we walk not in the trodden</div> + <div id="scene1.3.13">paths our very petticoats will catch them.</div> + </div> + <div id="speech8" class="character">ROSALIND</div> + <div class="dialog"> + <div id="scene1.3.14">I could shake them off my coat: these burs are in my heart.</div> + </div> + <div id="speech9" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.15">Hem them away.</div> + </div> + <div id="speech10" class="character">ROSALIND</div> + <div class="dialog"> + <div id="scene1.3.16">I would try, if I could cry 'hem' and have him.</div> + </div> + <div id="speech11" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.17">Come, come, wrestle with thy affections.</div> + </div> + <div id="speech12" class="character">ROSALIND</div> + <div class="dialog"> + <div id="scene1.3.18">O, they take the part of a better wrestler than myself!</div> + </div> + <div id="speech13" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.19">O, a good wish upon you! you will try in time, in</div> + <div id="scene1.3.20">despite of a fall. But, turning these jests out of</div> + <div id="scene1.3.21">service, let us talk in good earnest: is it</div> + <div id="scene1.3.22">possible, on such a sudden, you should fall into so</div> + <div id="scene1.3.23">strong a liking with old Sir Rowland's youngest son?</div> + </div> + <div id="speech14" class="character">ROSALIND</div> + <div class="dialog"> + <div id="scene1.3.24">The duke my father loved his father dearly.</div> + </div> + <div id="speech15" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.25">Doth it therefore ensue that you should love his son</div> + <div id="scene1.3.26">dearly? By this kind of chase, I should hate him,</div> + <div id="scene1.3.27">for my father hated his father dearly; yet I hate</div> + <div id="scene1.3.28">not Orlando.</div> + </div> + <div id="speech16" class="character">ROSALIND</div> + <div title="wtf" class="dialog"> + <div id="scene1.3.29">No, faith, hate him not, for my sake.</div> + </div> + <div id="speech17" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.30">Why should I not? doth he not deserve well?</div> + </div> + <div id="speech18" class="character">ROSALIND</div> + <div class="dialog"> + <div id="scene1.3.31">Let me love him for that, and do you love him</div> + <div id="scene1.3.32">because I do. Look, here comes the duke.</div> + </div> + <div id="speech19" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.33">With his eyes full of anger.</div> + <div class="direction">Enter DUKE FREDERICK, with Lords</div> + </div> + <div id="speech20" class="character">DUKE FREDERICK</div> + <div class="dialog"> + <div id="scene1.3.34">Mistress, dispatch you with your safest haste</div> + <div id="scene1.3.35">And get you from our court.</div> + </div> + <div id="speech21" class="character">ROSALIND</div> + <div class="dialog"> + <div id="scene1.3.36">Me, uncle?</div> + </div> + <div id="speech22" class="character">DUKE FREDERICK</div> + <div class="dialog"> + <div id="scene1.3.37">You, cousin</div> + <div id="scene1.3.38">Within these ten days if that thou be'st found</div> + <div id="scene1.3.39">So near our public court as twenty miles,</div> + <div id="scene1.3.40">Thou diest for it.</div> + </div> + <div id="speech23" class="character">ROSALIND</div> + <div class="dialog"> + <div id="scene1.3.41"> I do beseech your grace,</div> + <div id="scene1.3.42">Let me the knowledge of my fault bear with me:</div> + <div id="scene1.3.43">If with myself I hold intelligence</div> + <div id="scene1.3.44">Or have acquaintance with mine own desires,</div> + <div id="scene1.3.45">If that I do not dream or be not frantic,--</div> + <div id="scene1.3.46">As I do trust I am not--then, dear uncle,</div> + <div id="scene1.3.47">Never so much as in a thought unborn</div> + <div id="scene1.3.48">Did I offend your highness.</div> + </div> + <div id="speech24" class="character">DUKE FREDERICK</div> + <div class="dialog"> + <div id="scene1.3.49">Thus do all traitors:</div> + <div id="scene1.3.50">If their purgation did consist in words,</div> + <div id="scene1.3.51">They are as innocent as grace itself:</div> + <div id="scene1.3.52">Let it suffice thee that I trust thee not.</div> + </div> + <div id="speech25" class="character">ROSALIND</div> + <div class="dialog"> + <div id="scene1.3.53">Yet your mistrust cannot make me a traitor:</div> + <div id="scene1.3.54">Tell me whereon the likelihood depends.</div> + </div> + <div id="speech26" class="character">DUKE FREDERICK</div> + <div class="dialog"> + <div id="scene1.3.55">Thou art thy father's daughter; there's enough.</div> + </div> + <div id="speech27" class="character">ROSALIND</div> + <div class="dialog"> + <div id="scene1.3.56">So was I when your highness took his dukedom;</div> + <div id="scene1.3.57">So was I when your highness banish'd him:</div> + <div id="scene1.3.58">Treason is not inherited, my lord;</div> + <div id="scene1.3.59">Or, if we did derive it from our friends,</div> + <div id="scene1.3.60">What's that to me? my father was no traitor:</div> + <div id="scene1.3.61">Then, good my liege, mistake me not so much</div> + <div id="scene1.3.62">To think my poverty is treacherous.</div> + </div> + <div id="speech28" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.63">Dear sovereign, hear me speak.</div> + </div> + <div id="speech29" class="character">DUKE FREDERICK</div> + <div class="dialog"> + <div id="scene1.3.64">Ay, Celia; we stay'd her for your sake,</div> + <div id="scene1.3.65">Else had she with her father ranged along.</div> + </div> + <div id="speech30" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.66">I did not then entreat to have her stay;</div> + <div id="scene1.3.67">It was your pleasure and your own remorse:</div> + <div id="scene1.3.68">I was too young that time to value her;</div> + <div id="scene1.3.69">But now I know her: if she be a traitor,</div> + <div id="scene1.3.70">Why so am I; we still have slept together,</div> + <div id="scene1.3.71">Rose at an instant, learn'd, play'd, eat together,</div> + <div id="scene1.3.72">And wheresoever we went, like Juno's swans,</div> + <div id="scene1.3.73">Still we went coupled and inseparable.</div> + </div> + <div id="speech31" class="character">DUKE FREDERICK</div> + <div class="dialog"> + <div id="scene1.3.74">She is too subtle for thee; and her smoothness,</div> + <div id="scene1.3.75">Her very silence and her patience</div> + <div id="scene1.3.76">Speak to the people, and they pity her.</div> + <div id="scene1.3.77">Thou art a fool: she robs thee of thy name;</div> + <div id="scene1.3.78">And thou wilt show more bright and seem more virtuous</div> + <div id="scene1.3.79">When she is gone. Then open not thy lips:</div> + <div id="scene1.3.80">Firm and irrevocable is my doom</div> + <div id="scene1.3.81">Which I have pass'd upon her; she is banish'd.</div> + </div> + <div id="speech32" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.82">Pronounce that sentence then on me, my liege:</div> + <div id="scene1.3.83">I cannot live out of her company.</div> + </div> + <div id="speech33" class="character">DUKE FREDERICK</div> + <div class="dialog"> + <div id="scene1.3.84">You are a fool. You, niece, provide yourself:</div> + <div id="scene1.3.85">If you outstay the time, upon mine honour,</div> + <div id="scene1.3.86">And in the greatness of my word, you die.</div> + <div class="direction">Exeunt DUKE FREDERICK and Lords</div> + </div> + <div id="speech34" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.87">O my poor Rosalind, whither wilt thou go?</div> + <div id="scene1.3.88">Wilt thou change fathers? I will give thee mine.</div> + <div id="scene1.3.89">I charge thee, be not thou more grieved than I am.</div> + </div> + <div id="speech35" class="character">ROSALIND</div> + <div class="dialog"> + <div id="scene1.3.90">I have more cause.</div> + </div> + <div id="speech36" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.91"> Thou hast not, cousin;</div> + <div id="scene1.3.92">Prithee be cheerful: know'st thou not, the duke</div> + <div id="scene1.3.93">Hath banish'd me, his daughter?</div> + </div> + <div id="speech37" class="character">ROSALIND</div> + <div class="dialog"> + <div id="scene1.3.94">That he hath not.</div> + </div> + <div id="speech38" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.95">No, hath not? Rosalind lacks then the love</div> + <div id="scene1.3.96">Which teacheth thee that thou and I am one:</div> + <div id="scene1.3.97">Shall we be sunder'd? shall we part, sweet girl?</div> + <div id="scene1.3.98">No: let my father seek another heir.</div> + <div id="scene1.3.99">Therefore devise with me how we may fly,</div> + <div id="scene1.3.100">Whither to go and what to bear with us;</div> + <div id="scene1.3.101">And do not seek to take your change upon you,</div> + <div id="scene1.3.102">To bear your griefs yourself and leave me out;</div> + <div id="scene1.3.103">For, by this heaven, now at our sorrows pale,</div> + <div id="scene1.3.104">Say what thou canst, I'll go along with thee.</div> + </div> + <div id="speech39" class="character">ROSALIND</div> + <div class="dialog"> + <div id="scene1.3.105">Why, whither shall we go?</div> + </div> + <div id="speech40" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.106">To seek my uncle in the forest of Arden.</div> + </div> + <div id="speech41" class="character">ROSALIND</div> + <div class="dialog"> + <div id="scene1.3.107">Alas, what danger will it be to us,</div> + <div id="scene1.3.108">Maids as we are, to travel forth so far!</div> + <div id="scene1.3.109">Beauty provoketh thieves sooner than gold.</div> + </div> + <div id="speech42" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.110">I'll put myself in poor and mean attire</div> + <div id="scene1.3.111">And with a kind of umber smirch my face;</div> + <div id="scene1.3.112">The like do you: so shall we pass along</div> + <div id="scene1.3.113">And never stir assailants.</div> + </div> + <div id="speech43" class="character">ROSALIND</div> + <div class="dialog"> + <div id="scene1.3.114">Were it not better,</div> + <div id="scene1.3.115">Because that I am more than common tall,</div> + <div id="scene1.3.116">That I did suit me all points like a man?</div> + <div id="scene1.3.117">A gallant curtle-axe upon my thigh,</div> + <div id="scene1.3.118">A boar-spear in my hand; and--in my heart</div> + <div id="scene1.3.119">Lie there what hidden woman's fear there will--</div> + <div id="scene1.3.120">We'll have a swashing and a martial outside,</div> + <div id="scene1.3.121">As many other mannish cowards have</div> + <div id="scene1.3.122">That do outface it with their semblances.</div> + </div> + <div id="speech44" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.123">What shall I call thee when thou art a man?</div> + </div> + <div id="speech45" class="character">ROSALIND</div> + <div class="dialog"> + <div id="scene1.3.124">I'll have no worse a name than Jove's own page;</div> + <div id="scene1.3.125">And therefore look you call me Ganymede.</div> + <div id="scene1.3.126">But what will you be call'd?</div> + </div> + <div id="speech46" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.127">Something that hath a reference to my state</div> + <div id="scene1.3.128">No longer Celia, but Aliena.</div> + </div> + <div id="speech47" class="character">ROSALIND</div> + <div class="dialog"> + <div id="scene1.3.129">But, cousin, what if we assay'd to steal</div> + <div id="scene1.3.130">The clownish fool out of your father's court?</div> + <div id="scene1.3.131">Would he not be a comfort to our travel?</div> + </div> + <div id="speech48" class="character">CELIA</div> + <div class="dialog"> + <div id="scene1.3.132">He'll go along o'er the wide world with me;</div> + <div id="scene1.3.133">Leave me alone to woo him. Let's away,</div> + <div id="scene1.3.134">And get our jewels and our wealth together,</div> + <div id="scene1.3.135">Devise the fittest time and safest way</div> + <div id="scene1.3.136">To hide us from pursuit that will be made</div> + <div id="scene1.3.137">After my flight. Now go we in content</div> + <div id="scene1.3.138">To liberty and not to banishment.</div> + <div class="direction">Exeunt</div> + </div> + </div> + </div> +</div> +</body> +</html> diff --git a/browser/vendor/cssselect/tests/fixtures/xmllang.xml b/browser/vendor/cssselect/tests/fixtures/xmllang.xml new file mode 100644 index 000000000..a52a43395 --- /dev/null +++ b/browser/vendor/cssselect/tests/fixtures/xmllang.xml @@ -0,0 +1,12 @@ + +<test> + <a id="first" xml:lang="en">a</a> + <b id="second" xml:lang="en-US">b</b> + <c id="third" xml:lang="en-Nz">c</c> + <d id="fourth" xml:lang="En-us">d</d> + <e id="fifth" xml:lang="fr">e</e> + <f id="sixth" xml:lang="ru">f</f> + <g id="seventh" xml:lang="de"> + <h id="eighth" xml:lang="zh"/> + </g> +</test> diff --git a/browser/vendor/cssselect/tests/html_translator.rs b/browser/vendor/cssselect/tests/html_translator.rs new file mode 100644 index 000000000..26395342b --- /dev/null +++ b/browser/vendor/cssselect/tests/html_translator.rs @@ -0,0 +1,75 @@ +//! HTML translator output: case folding, the `lang` attribute, and the +//! HTML-specific pseudo-class blocks. + +use cssselect::{GenericTranslator, HtmlTranslator}; + +/// Translate with the HTML translator and an empty prefix. +fn html(css: &str) -> String { + HtmlTranslator::new() + .css_to_xpath_with_prefix(css, "") + .unwrap() +} + +/// Translate with the XHTML translator and an empty prefix. +fn xhtml(css: &str) -> String { + HtmlTranslator::with_xhtml(true) + .css_to_xpath_with_prefix(css, "") + .unwrap() +} + +#[test] +fn element_names_fold_to_lower_case() { + assert_eq!(html("DIV"), "div"); + assert_eq!(html("A[NAme]"), "a[@name]"); + // XHTML keeps names as written. + assert_eq!(xhtml("DIV"), "DIV"); + assert_eq!(xhtml("A[NAme]"), "A[@NAme]"); +} + +#[test] +fn link_pseudo() { + assert_eq!( + html(":link"), + "*[@href and (name(.) = 'a' or name(.) = 'link' or name(.) = 'area')]" + ); + // The generic translator never matches :link. + assert_eq!( + GenericTranslator::new() + .css_to_xpath_with_prefix(":link", "") + .unwrap(), + "*[0]" + ); +} + +#[test] +fn visited_pseudo_never_matches() { + assert_eq!(html(":visited"), "*[0]"); +} + +#[test] +fn checked_pseudo() { + assert_eq!( + html(":checked"), + "*[(@selected and name(.) = 'option') or (@checked and (name(.) = 'input' or name(.) = 'command')and (@type = 'checkbox' or @type = 'radio'))]" + ); +} + +#[test] +fn lang_function_uses_lang_attribute() { + assert_eq!( + html(":lang(en)"), + "*[ancestor-or-self::*[@lang][1][starts-with(concat(translate(@lang, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '-'), 'en-')]]" + ); +} + +#[test] +fn disabled_pseudo_block() { + let expected = "*[\n (\n @disabled and\n (\n (name(.) = 'input' and @type != 'hidden') or\n name(.) = 'button' or\n name(.) = 'select' or\n name(.) = 'textarea' or\n name(.) = 'command' or\n name(.) = 'fieldset' or\n name(.) = 'optgroup' or\n name(.) = 'option'\n )\n ) or (\n (\n (name(.) = 'input' and @type != 'hidden') or\n name(.) = 'button' or\n name(.) = 'select' or\n name(.) = 'textarea'\n )\n and ancestor::fieldset[@disabled]\n )\n ]"; + assert_eq!(html(":disabled"), expected); +} + +#[test] +fn enabled_pseudo_block() { + let expected = "*[\n (\n @href and (\n name(.) = 'a' or\n name(.) = 'link' or\n name(.) = 'area'\n )\n ) or (\n (\n name(.) = 'command' or\n name(.) = 'fieldset' or\n name(.) = 'optgroup'\n )\n and not(@disabled)\n ) or (\n (\n (name(.) = 'input' and @type != 'hidden') or\n name(.) = 'button' or\n name(.) = 'select' or\n name(.) = 'textarea' or\n name(.) = 'keygen'\n )\n and not (@disabled or ancestor::fieldset[@disabled])\n ) or (\n name(.) = 'option' and not(\n @disabled or ancestor::optgroup[@disabled]\n )\n )\n ]"; + assert_eq!(html(":enabled"), expected); +} diff --git a/browser/vendor/cssselect/tests/parse_errors.rs b/browser/vendor/cssselect/tests/parse_errors.rs new file mode 100644 index 000000000..e81ab7aea --- /dev/null +++ b/browser/vendor/cssselect/tests/parse_errors.rs @@ -0,0 +1,117 @@ +//! Exact `SelectorSyntaxError` message strings. + +use cssselect::{parse, SelectorError}; + +/// The error message for `css`, or `None` when it parses. +fn get_error(css: &str) -> Option<String> { + match parse(css) { + Err(SelectorError::Syntax(msg)) => Some(msg), + Err(other) => panic!("expected syntax error for {css:?}, got {other:?}"), + Ok(_) => None, + } +} + +/// Assert the error for `css` equals `expected`. +fn assert_error(css: &str, expected: &str) { + assert_eq!(get_error(css).as_deref(), Some(expected), "for {css:?}"); +} + +#[test] +fn structural_errors() { + assert_error( + "attributes(href)/html/body/a", + "Expected selector, got <DELIM '(' at 10>", + ); + assert_error( + "attributes(href)", + "Expected selector, got <DELIM '(' at 10>", + ); + assert_error("html/body/a", "Expected selector, got <DELIM '/' at 4>"); + assert_error(" ", "Expected selector, got <EOF at 1>"); + assert_error("div, ", "Expected selector, got <EOF at 5>"); + assert_error(" , div", "Expected selector, got <DELIM ',' at 1>"); + assert_error("p, , div", "Expected selector, got <DELIM ',' at 3>"); + assert_error("div > ", "Expected selector, got <EOF at 6>"); + assert_error(" > div", "Expected selector, got <DELIM '>' at 2>"); +} + +#[test] +fn name_and_attribute_errors() { + assert_error("foo|#bar", "Expected ident or '*', got <HASH 'bar' at 4>"); + assert_error("#.foo", "Expected selector, got <DELIM '#' at 0>"); + assert_error(".#foo", "Expected ident, got <HASH 'foo' at 1>"); + assert_error(":#foo", "Expected ident, got <HASH 'foo' at 1>"); + assert_error("[*]", "Expected '|', got <DELIM ']' at 2>"); + assert_error("[foo|]", "Expected ident, got <DELIM ']' at 5>"); + assert_error("[#]", "Expected ident or '*', got <DELIM '#' at 1>"); + assert_error("[foo=#]", "Expected string or ident, got <DELIM '#' at 5>"); + assert_error("[href]a", "Expected selector, got <IDENT 'a' at 6>"); + assert_eq!(get_error("[rel=stylesheet]"), None); + assert_error( + "[rel:stylesheet]", + "Operator expected, got <DELIM ':' at 4>", + ); + assert_error("[rel=stylesheet", "Expected ']', got <EOF at 15>"); +} + +#[test] +fn function_and_string_errors() { + assert_eq!(get_error(":lang(fr)"), None); + assert_error(":lang(fr", "Expected an argument, got <EOF at 8>"); + assert_error(":contains(\"foo", "Unclosed string at 10"); + assert_error("foo!", "Expected selector, got <DELIM '!' at 3>"); +} + +#[test] +fn misplaced_pseudo_elements() { + assert_error( + "a:before:empty", + "Got pseudo-element ::before not at the end of a selector", + ); + assert_error( + "li:before a", + "Got pseudo-element ::before not at the end of a selector", + ); + assert_error( + ":not(:before)", + "Got pseudo-element ::before inside :not() at 12", + ); + assert_error(":not(:not(a))", "Got nested :not()"); + assert_error( + ":is(:before)", + "Got pseudo-element ::before inside function", + ); + assert_error(":is(a b)", "Expected an argument, got <IDENT 'b' at 6>"); + assert_error( + ":where(:before)", + "Got pseudo-element ::before inside function", + ); + assert_error(":where(a b)", "Expected an argument, got <IDENT 'b' at 9>"); +} + +#[test] +fn scope_placement() { + assert_error( + ":scope > div :scope header", + "Got immediate child pseudo-element \":scope\" not at the start of a selector", + ); + assert_error( + "div :scope header", + "Got immediate child pseudo-element \":scope\" not at the start of a selector", + ); + assert_error("> div p", "Expected selector, got <DELIM '>' at 0>"); +} + +#[test] +fn has_arity() { + assert_error(":has(a, b)", "Expected an argument, got <DELIM ',' at 6>"); + assert_error(":has()", "Expected selector, got <EOF at 0>"); +} + +#[test] +fn is_where_empty_and_comma_edges() { + assert_error(":is()", "Expected selector, got <DELIM ')' at 4>"); + assert_error(":where()", "Expected selector, got <DELIM ')' at 7>"); + assert_error(":is(a,)", "Expected selector, got <DELIM ')' at 6>"); + assert_error(":is(,a)", "Expected selector, got <DELIM ',' at 4>"); +} diff --git a/browser/vendor/cssselect/tests/parser_repr.rs b/browser/vendor/cssselect/tests/parser_repr.rs new file mode 100644 index 000000000..1c558852c --- /dev/null +++ b/browser/vendor/cssselect/tests/parser_repr.rs @@ -0,0 +1,210 @@ +//! Parse-tree `repr` output and fast-path equivalence. + +use cssselect::parse; + +/// The `repr` of each parsed tree, asserting every pseudo-element is absent. +fn repr_parse(css: &str) -> Vec<String> { + let selectors = parse(css).unwrap(); + for s in &selectors { + assert!( + s.pseudo_element.is_none(), + "unexpected pseudo-element in {css:?}" + ); + } + selectors.iter().map(|s| s.parsed_tree.repr()).collect() +} + +/// Assert every spelling parses to the same tree reprs as the first. +fn parse_many(first: &str, others: &[&str]) -> Vec<String> { + let result = repr_parse(first); + for other in others { + assert_eq!(repr_parse(other), result, "mismatch for {other:?}"); + } + result +} + +#[test] +fn universal_and_namespaces() { + assert_eq!(parse_many("*", &["*|*"]), vec!["Element[*]"]); + assert_eq!(parse_many("*|foo", &["|foo"]), vec!["Element[foo]"]); + assert_eq!(parse_many("foo|*", &[]), vec!["Element[foo|*]"]); + assert_eq!(parse_many("foo|bar", &[]), vec!["Element[foo|bar]"]); +} + +#[test] +fn stacked_hash() { + assert_eq!( + parse_many("#foo#bar", &[]), + vec!["Hash[Hash[Element[*]#foo]#bar]"] + ); +} + +#[test] +fn combinator_whitespace_equivalence() { + assert_eq!( + parse_many( + "div>.foo", + &[ + "div> .foo", + "div >.foo", + "div > .foo", + "div \n> \t \t .foo", + "div\r>\n\n\n.foo", + "div\u{0c}>\u{0c}.foo", + ], + ), + vec!["CombinedSelector[Element[div] > Class[Element[*].foo]]"] + ); +} + +#[test] +fn selector_groups() { + assert_eq!( + parse_many( + "td.foo,.bar", + &["td.foo, .bar", "td.foo\t\r\n\u{0c} ,\t\r\n\u{0c} .bar"] + ), + vec!["Class[Element[td].foo]", "Class[Element[*].bar]"] + ); + assert_eq!( + parse_many("div, td.foo, div.bar span", &[]), + vec![ + "Element[div]", + "Class[Element[td].foo]", + "CombinedSelector[Class[Element[div].bar] <followed> Element[span]]", + ] + ); +} + +#[test] +fn attributes() { + assert_eq!( + parse_many("a[name]", &["a[ name\t]"]), + vec!["Attrib[Element[a][name]]"] + ); + assert_eq!( + parse_many("a [name]", &[]), + vec!["CombinedSelector[Element[a] <followed> Attrib[Element[*][name]]]"] + ); + assert_eq!( + parse_many("a[rel=\"include\"]", &["a[rel = include]"]), + vec!["Attrib[Element[a][rel = 'include']]"] + ); + assert_eq!( + parse_many("a[hreflang |= 'en']", &["a[hreflang|=en]"]), + vec!["Attrib[Element[a][hreflang |= 'en']]"] + ); +} + +#[test] +fn functions_and_pseudos() { + assert_eq!( + parse_many("div:nth-child(10)", &[]), + vec!["Function[Element[div]:nth-child(['10'])]"] + ); + assert_eq!( + parse_many(":nth-child(2n+2)", &[]), + vec!["Function[Element[*]:nth-child(['2', 'n', '+2'])]"] + ); + assert_eq!( + parse_many("div:nth-of-type(10)", &[]), + vec!["Function[Element[div]:nth-of-type(['10'])]"] + ); + assert_eq!( + parse_many("div div:nth-of-type(10) .aclass", &[]), + vec![ + "CombinedSelector[CombinedSelector[Element[div] <followed> \ +Function[Element[div]:nth-of-type(['10'])]] <followed> Class[Element[*].aclass]]" + ] + ); + assert_eq!( + parse_many("label:only", &[]), + vec!["Pseudo[Element[label]:only]"] + ); + assert_eq!( + parse_many("a:lang(fr)", &[]), + vec!["Function[Element[a]:lang(['fr'])]"] + ); + assert_eq!( + parse_many("div:contains(\"foo\")", &[]), + vec!["Function[Element[div]:contains(['foo'])]"] + ); + assert_eq!( + parse_many("div#foobar", &[]), + vec!["Hash[Element[div]#foobar]"] + ); + assert_eq!( + parse_many("td:first", &[]), + vec!["Pseudo[Element[td]:first]"] + ); + assert_eq!( + parse_many("td :first", &[]), + vec!["CombinedSelector[Element[td] <followed> Pseudo[Element[*]:first]]"] + ); +} + +#[test] +fn logical_combinators() { + assert_eq!( + parse_many("div:not(div.foo)", &[]), + vec!["Negation[Element[div]:not(Class[Element[div].foo])]"] + ); + assert_eq!( + parse_many("div:has(div.foo)", &[]), + vec!["Relation[Element[div]:has(Selector[Class[Element[div].foo]])]"] + ); + assert_eq!( + parse_many("div:is(.foo, #bar)", &[]), + vec!["Matching[Element[div]:is(Class[Element[*].foo], Hash[Element[*]#bar])]"] + ); + assert_eq!( + parse_many(":is(:hover, :visited)", &[]), + vec!["Matching[Element[*]:is(Pseudo[Element[*]:hover], Pseudo[Element[*]:visited])]"] + ); + assert_eq!( + parse_many(":where(:hover, :visited)", &[]), + vec!["SpecificityAdjustment[Element[*]:where(Pseudo[Element[*]:hover], Pseudo[Element[*]:visited])]"] + ); + assert_eq!( + parse_many("td ~ th", &[]), + vec!["CombinedSelector[Element[td] ~ Element[th]]"] + ); +} + +#[test] +fn scope_placement() { + assert_eq!( + parse_many(":scope > foo", &[" :scope > foo"]), + vec!["CombinedSelector[Pseudo[Element[*]:scope] > Element[foo]]"] + ); + assert_eq!( + parse_many(":scope > foo bar > div", &[]), + vec![ + "CombinedSelector[CombinedSelector[CombinedSelector[Pseudo[Element[*]:scope] > \ +Element[foo]] <followed> Element[bar]] > Element[div]]" + ] + ); + assert_eq!( + parse_many(":scope > #foo #bar", &[]), + vec![ + "CombinedSelector[CombinedSelector[Pseudo[Element[*]:scope] > \ +Hash[Element[*]#foo]] <followed> Hash[Element[*]#bar]]" + ] + ); +} + +#[test] +fn fast_path_matches_slow_path() { + // The fast-path regexes must yield the same trees as the tokenizer route. + assert_eq!(repr_parse("foo"), repr_parse(" foo ")); + assert_eq!(repr_parse("#bar"), repr_parse(" #bar ")); + assert_eq!(repr_parse("foo#bar"), repr_parse(" foo#bar ")); + assert_eq!(repr_parse(".bar"), repr_parse(" .bar ")); + assert_eq!(repr_parse("foo.bar"), repr_parse(" foo.bar ")); + + assert_eq!(repr_parse("foo"), vec!["Element[foo]"]); + assert_eq!(repr_parse("#bar"), vec!["Hash[Element[*]#bar]"]); + assert_eq!(repr_parse("foo#bar"), vec!["Hash[Element[foo]#bar]"]); + assert_eq!(repr_parse(".bar"), vec!["Class[Element[*].bar]"]); + assert_eq!(repr_parse("foo.bar"), vec!["Class[Element[foo].bar]"]); +} diff --git a/browser/vendor/cssselect/tests/pseudo_elements.rs b/browser/vendor/cssselect/tests/pseudo_elements.rs new file mode 100644 index 000000000..968a32696 --- /dev/null +++ b/browser/vendor/cssselect/tests/pseudo_elements.rs @@ -0,0 +1,199 @@ +//! Pseudo-element parsing, `repr`, and default translation behavior. + +use cssselect::{parse, GenericTranslator, PseudoElement, PseudoElements, SelectorError}; + +/// The tree `repr` and the pseudo-element as a display string for each selector. +fn parse_pseudo(css: &str) -> Vec<(String, Option<String>)> { + parse(css) + .unwrap() + .iter() + .map(|s| { + let pe = s.pseudo_element.as_ref().map(pe_display); + (s.parsed_tree.repr(), pe) + }) + .collect() +} + +/// The display form of a pseudo-element: the ident, or the functional repr. +fn pe_display(pe: &PseudoElement) -> String { + match pe { + PseudoElement::Ident(name) => name.clone(), + PseudoElement::Functional(f) => f.repr(), + } +} + +/// Parse one selector and return its (tree repr, pseudo-element) pair. +fn parse_one(css: &str) -> (String, Option<String>) { + let result = parse_pseudo(css); + assert_eq!(result.len(), 1, "expected one selector for {css:?}"); + result.into_iter().next().unwrap() +} + +#[test] +fn pseudo_classes_have_no_pseudo_element() { + assert_eq!(parse_one("foo"), ("Element[foo]".into(), None)); + assert_eq!(parse_one("*"), ("Element[*]".into(), None)); + assert_eq!( + parse_one(":empty"), + ("Pseudo[Element[*]:empty]".into(), None) + ); + assert_eq!( + parse_one(":scope"), + ("Pseudo[Element[*]:scope]".into(), None) + ); +} + +#[test] +fn css21_single_colon_pseudo_elements() { + assert_eq!( + parse_one(":BEfore"), + ("Element[*]".into(), Some("before".into())) + ); + assert_eq!( + parse_one(":aftER"), + ("Element[*]".into(), Some("after".into())) + ); + assert_eq!( + parse_one(":First-Line"), + ("Element[*]".into(), Some("first-line".into())) + ); + assert_eq!( + parse_one(":First-Letter"), + ("Element[*]".into(), Some("first-letter".into())) + ); + + assert_eq!( + parse_one("::befoRE"), + ("Element[*]".into(), Some("before".into())) + ); + assert_eq!( + parse_one("::AFter"), + ("Element[*]".into(), Some("after".into())) + ); + assert_eq!( + parse_one("::firsT-linE"), + ("Element[*]".into(), Some("first-line".into())) + ); + assert_eq!( + parse_one("::firsT-letteR"), + ("Element[*]".into(), Some("first-letter".into())) + ); +} + +#[test] +fn arbitrary_pseudo_elements() { + assert_eq!( + parse_one("::text-content"), + ("Element[*]".into(), Some("text-content".into())) + ); + assert_eq!( + parse_one("::attr(name)"), + ( + "Element[*]".into(), + Some("FunctionalPseudoElement[::attr(['name'])]".into()) + ) + ); + assert_eq!( + parse_one("::Selection"), + ("Element[*]".into(), Some("selection".into())) + ); + assert_eq!( + parse_one("foo:after"), + ("Element[foo]".into(), Some("after".into())) + ); + assert_eq!( + parse_one("foo::selection"), + ("Element[foo]".into(), Some("selection".into())) + ); +} + +#[test] +fn pseudo_element_at_end_of_chain() { + assert_eq!( + parse_one("lorem#ipsum ~ a#b.c[href]:empty::selection"), + ( + "CombinedSelector[Hash[Element[lorem]#ipsum] ~ \ +Pseudo[Attrib[Class[Hash[Element[a]#b].c][href]]:empty]]" + .into(), + Some("selection".into()) + ) + ); +} + +#[test] +fn per_selector_in_group() { + assert_eq!( + parse_pseudo(":scope > div, foo bar"), + vec![ + ( + "CombinedSelector[Pseudo[Element[*]:scope] > Element[div]]".into(), + None + ), + ( + "CombinedSelector[Element[foo] <followed> Element[bar]]".into(), + None + ), + ] + ); + assert_eq!( + parse_pseudo("foo:before, bar, baz:after"), + vec![ + ("Element[foo]".into(), Some("before".into())), + ("Element[bar]".into(), None), + ("Element[baz]".into(), Some("after".into())), + ] + ); +} + +#[test] +fn css21_pseudo_elements_ignored_by_default() { + for pseudo in ["after", "before", "first-line", "first-letter"] { + let css = alloc_format(pseudo); + let selectors = parse(&css).unwrap(); + assert_eq!(selectors.len(), 1); + let sel = &selectors[0]; + assert_eq!(pe_display(sel.pseudo_element.as_ref().unwrap()), pseudo); + assert_eq!( + GenericTranslator::new() + .selector_to_xpath_with(sel, "", PseudoElements::Ignore) + .unwrap(), + "e" + ); + } +} + +/// Build `e:<pseudo>`. +fn alloc_format(pseudo: &str) -> String { + let mut s = String::from("e:"); + s.push_str(pseudo); + s +} + +#[test] +fn pseudo_elements_unsupported_when_translated() { + let tr = GenericTranslator::new(); + let selectors = parse("e::foo").unwrap(); + let sel = &selectors[0]; + assert_eq!(pe_display(sel.pseudo_element.as_ref().unwrap()), "foo"); + assert_eq!( + tr.selector_to_xpath_with(sel, "", PseudoElements::Ignore) + .unwrap(), + "e" + ); + match tr.selector_to_xpath_with(sel, "descendant-or-self::", PseudoElements::Translate) { + Err(SelectorError::Expression(_)) => {} + other => panic!("expected ExpressionError, got {other:?}"), + } +} + +#[test] +fn unicode_repr_regression() { + // The dotted capital I is preserved. ASCII lowercasing leaves it alone. + let selectors = parse(":fİrst-child").unwrap(); + assert_eq!( + selectors[0].parsed_tree.repr(), + "Pseudo[Element[*]:fİrst-child]" + ); + let scope = parse(":scope").unwrap(); + assert_eq!(scope[0].parsed_tree.repr(), "Pseudo[Element[*]:scope]"); +} diff --git a/browser/vendor/cssselect/tests/quoting.rs b/browser/vendor/cssselect/tests/quoting.rs new file mode 100644 index 000000000..77c0ddc34 --- /dev/null +++ b/browser/vendor/cssselect/tests/quoting.rs @@ -0,0 +1,96 @@ +//! XPath string-literal quoting, unicode survival, and unicode escapes. + +use cssselect::GenericTranslator; + +/// Translate with the default prefix. +fn xpath(css: &str) -> String { + GenericTranslator::new().css_to_xpath(css).unwrap() +} + +#[test] +fn unicode_class_survives() { + let css = ".a\u{c1}b"; + let result = xpath(css); + assert!(result.contains("a\u{c1}b")); + // The ASCII transcription uses XML character references for non-ASCII. + let ascii = ascii_xmlcharref(&result); + assert_eq!( + ascii, + "descendant-or-self::*[@class and contains(\ +concat(' ', normalize-space(@class), ' '), ' a&#193;b ')]" + ); +} + +/// Replace every non-ASCII character with its `&#N;` XML character reference. +fn ascii_xmlcharref(s: &str) -> String { + let mut out = String::new(); + for c in s.chars() { + if c.is_ascii() { + out.push(c); + } else { + out.push_str(&format!("&#{};", c as u32)); + } + } + out +} + +#[test] +fn quote_selection() { + assert_eq!( + xpath("*[aval=\"'\"]"), + "descendant-or-self::*[@aval = \"'\"]" + ); + assert_eq!( + xpath("*[aval=\"'''\"]"), + "descendant-or-self::*[@aval = \"'''\"]" + ); + assert_eq!(xpath("*[aval='\"']"), "descendant-or-self::*[@aval = '\"']"); + assert_eq!( + xpath("*[aval='\"\"\"']"), + "descendant-or-self::*[@aval = '\"\"\"']" + ); + assert_eq!( + xpath(":scope > div[dataimg=\"<testmessage>\"]"), + "descendant-or-self::*[1]/div[@dataimg = '<testmessage>']" + ); +} + +#[test] +fn unicode_escapes() { + // \22 is a double quote, \20 is a space. + assert_eq!( + xpath(r#"*[aval="\'\22\'"]"#), + "descendant-or-self::*[@aval = concat(\"'\",'\"',\"'\")]" + ); + assert_eq!( + xpath(r#"*[aval="\'\22 2\'"]"#), + "descendant-or-self::*[@aval = concat(\"'\",'\"2',\"'\")]" + ); + assert_eq!( + xpath(r#"*[aval="\'\20 \'"]"#), + "descendant-or-self::*[@aval = \"' '\"]" + ); + assert_eq!( + xpath("*[aval=\"'\\20\r\n '\"]"), + "descendant-or-self::*[@aval = \"' '\"]" + ); +} + +/// Translate with an empty prefix. +fn xpath_bare(css: &str) -> String { + GenericTranslator::new() + .css_to_xpath_with_prefix(css, "") + .unwrap() +} + +#[test] +fn both_quotes_force_concat() { + // A value with both quote kinds builds a concat() through the real + // attribute-equals path, not only the helper. + assert_eq!( + xpath_bare("*[a=\"it's a \\\"q\\\"\"]"), + "*[@a = concat('it',\"'\",'s a \"q\"')]" + ); + // The same split feeds :contains(). + assert_eq!(xpath_bare("*:contains(\"a'b\")"), "*[contains(., \"a'b\")]"); +} diff --git a/browser/vendor/cssselect/tests/select.rs b/browser/vendor/cssselect/tests/select.rs new file mode 100644 index 000000000..672ea75aa --- /dev/null +++ b/browser/vendor/cssselect/tests/select.rs @@ -0,0 +1,114 @@ +//! Selection tier: run the generated XPath against a real engine. +//! +//! These cases confirm the generated XPath selects the right nodes, not only +//! that the string matches a golden. They run against the clean XML fixtures, +//! which a standard XML parser accepts. Build with +//! `--features xpath-engine-tests` to include this file. + +use cssselect::GenericTranslator; +use sxd_document::parser; +use sxd_xpath::{Context, Factory, Value}; + +const OPERATOR_PRECEDENCE: &str = include_str!("fixtures/operator_precedence.xml"); + +/// A small clean-XML corpus for structural and attribute selection. The engine +/// here lacks the XPath `lang()` function, so the generic `:lang()` cases live +/// in the string-parity tests instead. +const CORPUS: &str = r#" +<root> + <ol id="list" class="a b c"> + <li id="one">first</li> + <li id="two" class="x">second</li> + <li id="three" class="x y">third</li> + <li id="four"></li> + </ol> + <p id="para">text</p> + <a id="link" href="http://example.org/page" hreflang="en-US">link</a> +</root> +"#; + +/// Run a CSS selector against an XML fixture and return the matched `id`s in +/// document order. +fn select_ids(fixture: &str, selector: &str) -> Vec<String> { + let package = parser::parse(fixture).expect("fixture parses as XML"); + let document = package.as_document(); + let xpath_str = GenericTranslator::new() + .css_to_xpath(selector) + .expect("selector translates"); + + let factory = Factory::new(); + let xpath = factory + .build(&xpath_str) + .expect("xpath compiles") + .expect("xpath is not empty"); + let context = Context::new(); + let value = xpath + .evaluate(&context, document.root()) + .expect("xpath evaluates"); + + let mut ids = Vec::new(); + if let Value::Nodeset(nodes) = value { + for node in nodes.document_order() { + if let Some(element) = node.element() { + let id = element.attribute_value("id").unwrap_or("nil").to_string(); + ids.push(id); + } + } + } + ids +} + +#[test] +fn structural_selection() { + assert_eq!(select_ids(CORPUS, "li"), ["one", "two", "three", "four"]); + assert_eq!(select_ids(CORPUS, "li:first-child"), ["one"]); + assert_eq!(select_ids(CORPUS, "li:last-child"), ["four"]); + assert_eq!(select_ids(CORPUS, "li:nth-child(2)"), ["two"]); + assert_eq!(select_ids(CORPUS, "li:nth-child(odd)"), ["one", "three"]); + assert_eq!(select_ids(CORPUS, "li:empty"), ["four"]); + assert_eq!(select_ids(CORPUS, "ol > li:nth-of-type(3)"), ["three"]); +} + +#[test] +fn attribute_and_class_selection() { + assert_eq!(select_ids(CORPUS, ".x"), ["two", "three"]); + assert_eq!(select_ids(CORPUS, "li.x.y"), ["three"]); + assert_eq!(select_ids(CORPUS, "[href]"), ["link"]); + assert_eq!(select_ids(CORPUS, "[href^=\"http\"]"), ["link"]); + assert_eq!(select_ids(CORPUS, "[href$=\"page\"]"), ["link"]); + assert_eq!(select_ids(CORPUS, "[href*=\"example\"]"), ["link"]); + assert_eq!(select_ids(CORPUS, "[class~=\"y\"]"), ["three"]); + assert_eq!(select_ids(CORPUS, "[hreflang|=\"en\"]"), ["link"]); + assert!(select_ids(CORPUS, "[hreflang|=\"e\"]").is_empty()); + assert!(select_ids(CORPUS, "[href*=\"\"]").is_empty()); +} + +#[test] +fn combinator_selection() { + assert_eq!(select_ids(CORPUS, "li + li"), ["two", "three", "four"]); + assert_eq!(select_ids(CORPUS, "li ~ li"), ["two", "three", "four"]); + assert_eq!( + select_ids(CORPUS, "ol > li"), + ["one", "two", "three", "four"] + ); + assert_eq!(select_ids(CORPUS, "ol li:not(.x)"), ["one", "four"]); + assert_eq!(select_ids(CORPUS, "ol:has(li.y)"), ["list"]); + assert_eq!(select_ids(CORPUS, "li:is(#one, #four)"), ["one", "four"]); +} + +#[test] +fn operator_precedence() { + // `:first-or-second` is not a built-in pseudo-class. Use plain selectors + // that the engine can evaluate and check the selected ids. + assert_eq!( + select_with_id_predicate(":has(*)", "a"), + Vec::<String>::new() + ); + assert_eq!(select_with_id_predicate("[href]", "a"), ["second", "third"]); +} + +/// Select `tag` elements that match `selector`, returning their ids. +fn select_with_id_predicate(selector: &str, tag: &str) -> Vec<String> { + let combined = format!("{tag}{selector}"); + select_ids(OPERATOR_PRECEDENCE, &combined) +} diff --git a/browser/vendor/cssselect/tests/series.rs b/browser/vendor/cssselect/tests/series.rs new file mode 100644 index 000000000..f796c3480 --- /dev/null +++ b/browser/vendor/cssselect/tests/series.rs @@ -0,0 +1,78 @@ +//! Direct `parse_series` results on `:nth-child` arguments. + +use cssselect::{parse, parse_series, Token, TokenType, Tree}; + +/// Parse `:nth-child(<css>)` and run `parse_series` on its arguments. +fn series(css: &str) -> Option<(i64, i64)> { + let input = alloc_nth(css); + let selectors = parse(&input).unwrap(); + let arguments = match &selectors[0].parsed_tree { + Tree::Function { arguments, .. } => arguments.clone(), + other => panic!("expected a function tree, got {other:?}"), + }; + parse_series(&arguments).ok() +} + +/// Build `:nth-child(<css>)`. +fn alloc_nth(css: &str) -> String { + let mut s = String::from(":nth-child("); + s.push_str(css); + s.push(')'); + s +} + +#[test] +fn positive_b() { + assert_eq!(series("1n+3"), Some((1, 3))); + assert_eq!(series("1n +3"), Some((1, 3))); + assert_eq!(series("1n + 3"), Some((1, 3))); + assert_eq!(series("1n+ 3"), Some((1, 3))); +} + +#[test] +fn negative_b() { + assert_eq!(series("1n-3"), Some((1, -3))); + assert_eq!(series("1n -3"), Some((1, -3))); + assert_eq!(series("1n - 3"), Some((1, -3))); + assert_eq!(series("1n- 3"), Some((1, -3))); + assert_eq!(series("n-5"), Some((1, -5))); +} + +#[test] +fn keywords_and_coefficients() { + assert_eq!(series("odd"), Some((2, 1))); + assert_eq!(series("even"), Some((2, 0))); + assert_eq!(series("3n"), Some((3, 0))); + assert_eq!(series("n"), Some((1, 0))); + assert_eq!(series("+n"), Some((1, 0))); + assert_eq!(series("-n"), Some((-1, 0))); + assert_eq!(series("5"), Some((0, 5))); +} + +#[test] +fn signed_coefficients() { + assert_eq!(series("-n-2"), Some((-1, -2))); + assert_eq!(series("-2n+4"), Some((-2, 4))); + assert_eq!(series("10n-10"), Some((10, -10))); + assert_eq!(series("2n-1"), Some((2, -1))); + assert_eq!(series("0"), Some((0, 0))); + assert_eq!(series("-0"), Some((0, 0))); + assert_eq!(series("+5"), Some((0, 5))); + assert_eq!(series("-5"), Some((0, -5))); + assert_eq!(series("n+0"), Some((1, 0))); +} + +#[test] +fn invalid() { + assert_eq!(series("foo"), None); + assert_eq!(series("n+"), None); + assert_eq!(series("n-"), None); + assert_eq!(series("-n+"), None); +} + +#[test] +fn string_token_is_rejected() { + // A string token anywhere in a series is an error. + let tokens = vec![Token::new(TokenType::String, "2", 0)]; + assert!(parse_series(&tokens).is_err()); +} diff --git a/browser/vendor/cssselect/tests/specificity.rs b/browser/vendor/cssselect/tests/specificity.rs new file mode 100644 index 000000000..740e581bf --- /dev/null +++ b/browser/vendor/cssselect/tests/specificity.rs @@ -0,0 +1,69 @@ +//! Specificity triples for a range of selectors. + +use cssselect::{parse, Specificity}; + +/// The specificity of a single-selector input. +fn specificity(css: &str) -> Specificity { + let selectors = parse(css).unwrap(); + assert_eq!(selectors.len(), 1, "expected one selector for {css:?}"); + selectors[0].specificity() +} + +#[test] +fn simple_selectors() { + assert_eq!(specificity("*"), (0, 0, 0)); + assert_eq!(specificity(" foo"), (0, 0, 1)); + assert_eq!(specificity(":empty "), (0, 1, 0)); + assert_eq!(specificity(":before"), (0, 0, 1)); + assert_eq!(specificity("*:before"), (0, 0, 1)); + assert_eq!(specificity(":nth-child(2)"), (0, 1, 0)); + assert_eq!(specificity(".bar"), (0, 1, 0)); + assert_eq!(specificity("[baz]"), (0, 1, 0)); + assert_eq!(specificity("[baz=\"4\"]"), (0, 1, 0)); + assert_eq!(specificity("[baz^=\"4\"]"), (0, 1, 0)); + assert_eq!(specificity("#lipsum"), (1, 0, 0)); + assert_eq!(specificity("::attr(name)"), (0, 0, 1)); +} + +#[test] +fn negation_passes_through() { + assert_eq!(specificity(":not(*)"), (0, 0, 0)); + assert_eq!(specificity(":not(foo)"), (0, 0, 1)); + assert_eq!(specificity(":not(.foo)"), (0, 1, 0)); + assert_eq!(specificity(":not([foo])"), (0, 1, 0)); + assert_eq!(specificity(":not(:empty)"), (0, 1, 0)); + assert_eq!(specificity(":not(#foo)"), (1, 0, 0)); +} + +#[test] +fn has_relation() { + assert_eq!(specificity(":has(*)"), (0, 0, 0)); + assert_eq!(specificity(":has(foo)"), (0, 0, 1)); + assert_eq!(specificity(":has(.foo)"), (0, 1, 0)); + assert_eq!(specificity(":has(> foo)"), (0, 0, 1)); +} + +#[test] +fn matching_and_where() { + assert_eq!(specificity(":is(.foo, #bar)"), (1, 0, 0)); + assert_eq!(specificity(":is(:hover, :visited)"), (0, 1, 0)); + assert_eq!(specificity(":where(:hover, :visited)"), (0, 0, 0)); + assert_eq!(specificity("div:is(.x)"), (0, 1, 1)); + assert_eq!(specificity("div:where(.x)"), (0, 0, 1)); +} + +#[test] +fn pseudo_element_bump() { + assert_eq!(specificity("foo:empty"), (0, 1, 1)); + assert_eq!(specificity("foo:before"), (0, 0, 2)); + assert_eq!(specificity("foo::before"), (0, 0, 2)); + assert_eq!(specificity("foo:empty::before"), (0, 1, 2)); +} + +#[test] +fn combined() { + assert_eq!( + specificity("#lorem + foo#ipsum:first-child > bar:first-line"), + (2, 1, 3) + ); +} diff --git a/browser/vendor/cssselect/tests/tokenizer.rs b/browser/vendor/cssselect/tests/tokenizer.rs new file mode 100644 index 000000000..25a65c5aa --- /dev/null +++ b/browser/vendor/cssselect/tests/tokenizer.rs @@ -0,0 +1,116 @@ +//! Tokenizer output and escape handling. + +use cssselect::{tokenize, SelectorError}; + +/// Render a token list as their `Display` (Python `repr`) strings. +fn token_strs(css: &str) -> Vec<String> { + tokenize(css) + .unwrap() + .iter() + .map(|t| t.to_string()) + .collect() +} + +#[test] +fn rich_case() { + // The byte between `f` and `[` is a no-break space U+00A0, not a regular + // space. CSS does not treat it as whitespace, so it folds into the ident. + let input = "E\\ é > f\u{a0}[a~=\"y\\\"x\"]:nth(/* fu /]* */-3.7)"; + let tokens = token_strs(input); + assert_eq!( + tokens, + vec![ + "<IDENT 'E é' at 0>", + "<S ' ' at 4>", + "<DELIM '>' at 5>", + "<S ' ' at 6>", + // the no-break space is not whitespace in CSS + "<IDENT 'f\u{a0}' at 7>", + "<DELIM '[' at 9>", + "<IDENT 'a' at 10>", + "<DELIM '~' at 11>", + "<DELIM '=' at 12>", + "<STRING 'y\"x' at 13>", + "<DELIM ']' at 19>", + "<DELIM ':' at 20>", + "<IDENT 'nth' at 21>", + "<DELIM '(' at 24>", + "<NUMBER '-3.7' at 37>", + "<DELIM ')' at 41>", + "<EOF at 42>", + ] + ); +} + +#[test] +fn unclosed_string() { + match tokenize("'foo") { + Err(SelectorError::Syntax(msg)) => assert_eq!(msg, "Unclosed string at 0"), + other => panic!("expected unclosed string error, got {other:?}"), + } +} + +#[test] +fn unicode_escape_over_max() { + // A code point above the Unicode maximum clamps to U+FFFD. + let tokens = tokenize(r"\110000").unwrap(); + assert_eq!(tokens[0].value_str(), "\u{FFFD}"); +} + +#[test] +fn negative_ident_vs_number() { + // `-foo` is an identifier, `-3` is a number. + assert_eq!(tokenize("-foo").unwrap()[0].value_str(), "-foo"); + assert_eq!(tokenize("-3").unwrap()[0].value_str(), "-3"); +} + +#[test] +fn unterminated_comment_consumes_to_end() { + // A `/*` with no close swallows the rest and leaves only EOF. + let tokens = tokenize("a /* unterminated").unwrap(); + assert_eq!(tokens.last().unwrap().to_string(), "<EOF at 17>"); +} + +#[test] +fn surrogate_escape_folds_to_replacement() { + // A lone surrogate escape cannot live in a Rust string, so it folds to + // U+FFFD, the same value an over-max escape produces. + assert_eq!(tokenize(r"\D800").unwrap()[0].value_str(), "\u{FFFD}"); + assert_eq!(tokenize(r"\DFFF").unwrap()[0].value_str(), "\u{FFFD}"); + // A zero escape still maps to the null character. + assert_eq!(tokenize(r"\0").unwrap()[0].value_str(), "\u{0}"); +} + +#[test] +fn comment_between_idents_is_skipped() { + // The comment splits two idents and contributes no token. + let tokens = token_strs("a/* c */b"); + assert_eq!( + tokens, + vec!["<IDENT 'a' at 0>", "<IDENT 'b' at 8>", "<EOF at 9>"] + ); +} + +#[test] +fn line_continuation_in_string_is_removed() { + // A backslash before a newline inside a string drops both characters. + let value = tokenize("'line\\\na end'").unwrap()[0] + .value_str() + .to_string(); + assert_eq!(value, "linea end"); +} + +#[test] +fn hex_escape_in_ident() { + // `\41` is the hex escape for 'A'. + assert_eq!(tokenize(r"foo\41").unwrap()[0].value_str(), "fooA"); +} + +#[test] +fn leading_dot_and_signed_numbers() { + // The number matcher accepts a leading dot and an optional sign. + assert_eq!(tokenize("+.5").unwrap()[0].value_str(), "+.5"); + assert_eq!(tokenize("-.5").unwrap()[0].value_str(), "-.5"); + assert_eq!(tokenize(".5").unwrap()[0].value_str(), ".5"); + assert_eq!(tokenize("12.5").unwrap()[0].value_str(), "12.5"); +} diff --git a/browser/vendor/cssselect/tests/translation.rs b/browser/vendor/cssselect/tests/translation.rs new file mode 100644 index 000000000..ce71a75c2 --- /dev/null +++ b/browser/vendor/cssselect/tests/translation.rs @@ -0,0 +1,374 @@ +//! CSS to XPath translation strings produced by the generic translator. +//! +//! Each case asserts the exact XPath string with an empty prefix, pinning the +//! output character for character. + +use cssselect::{GenericTranslator, SelectorError}; + +/// Translate with an empty prefix. +fn xpath(css: &str) -> String { + GenericTranslator::new() + .css_to_xpath_with_prefix(css, "") + .unwrap() +} + +/// Translate and expect an expression error. +fn err(css: &str) { + match GenericTranslator::new().css_to_xpath_with_prefix(css, "") { + Err(SelectorError::Expression(_)) => {} + other => panic!("expected ExpressionError for {css:?}, got {other:?}"), + } +} + +/// Translate and return the expression error text. +fn err_text(css: &str) -> String { + match GenericTranslator::new().css_to_xpath_with_prefix(css, "") { + Err(SelectorError::Expression(msg)) => msg, + other => panic!("expected ExpressionError for {css:?}, got {other:?}"), + } +} + +#[test] +fn elements_and_namespaces() { + assert_eq!(xpath("*"), "*"); + assert_eq!(xpath("e"), "e"); + assert_eq!(xpath("*|e"), "e"); + assert_eq!(xpath("e|f"), "e:f"); +} + +#[test] +fn attribute_operators() { + assert_eq!(xpath("e[foo]"), "e[@foo]"); + assert_eq!(xpath("e[foo|bar]"), "e[@foo:bar]"); + assert_eq!(xpath("e[foo=\"bar\"]"), "e[@foo = 'bar']"); + assert_eq!( + xpath("e[foo~=\"bar\"]"), + "e[@foo and contains(concat(' ', normalize-space(@foo), ' '), ' bar ')]" + ); + assert_eq!( + xpath("e[foo^=\"bar\"]"), + "e[@foo and starts-with(@foo, 'bar')]" + ); + assert_eq!( + xpath("e[foo$=\"bar\"]"), + "e[@foo and substring(@foo, string-length(@foo)-2) = 'bar']" + ); + assert_eq!( + xpath("e[foo*=\"bar\"]"), + "e[@foo and contains(@foo, 'bar')]" + ); + assert_eq!( + xpath("e[hreflang|=\"en\"]"), + "e[@hreflang and (@hreflang = 'en' or starts-with(@hreflang, 'en-'))]" + ); +} + +#[test] +fn attribute_different_operator() { + assert_eq!(xpath("e[foo!=\"bar\"]"), "e[not(@foo) or @foo != 'bar']"); + assert_eq!(xpath("e[foo!=\"\"]"), "e[@foo != '']"); +} + +#[test] +fn matching_keeps_the_outer_selector_conjunctive() { + assert_eq!( + xpath("#root:is(.a, .b)"), + "*[(@id = 'root') and ((@class and contains(concat(' ', normalize-space(@class), ' '), ' a ')) or (@class and contains(concat(' ', normalize-space(@class), ' '), ' b ')))]" + ); +} + +#[test] +fn nth_child_family() { + assert_eq!( + xpath("e:nth-child(1)"), + "e[count(preceding-sibling::*) = 0]" + ); + assert_eq!(xpath("e:nth-child(n)"), "e"); + assert_eq!(xpath("e:nth-child(n+1)"), "e"); + assert_eq!(xpath("e:nth-child(n-10)"), "e"); + assert_eq!( + xpath("e:nth-child(n+2)"), + "e[count(preceding-sibling::*) >= 1]" + ); + assert_eq!(xpath("e:nth-child(-n)"), "e[0]"); + assert_eq!( + xpath("e:nth-child(-n+1)"), + "e[count(preceding-sibling::*) <= 0]" + ); + assert_eq!( + xpath("e:nth-child(3n+2)"), + "e[(count(preceding-sibling::*) >= 1) and ((count(preceding-sibling::*) +2) mod 3 = 0)]" + ); + assert_eq!( + xpath("e:nth-child(3n-2)"), + "e[count(preceding-sibling::*) mod 3 = 0]" + ); + assert_eq!( + xpath("e:nth-child(-n+6)"), + "e[count(preceding-sibling::*) <= 5]" + ); +} + +#[test] +fn nth_child_negative_step() { + // A negative `a` keeps its sign in the emitted `mod` and uses a + // non-negative offset. + assert_eq!( + xpath("e:nth-child(-3n+2)"), + "e[(count(preceding-sibling::*) <= 1) and ((count(preceding-sibling::*) +2) mod -3 = 0)]" + ); + assert_eq!( + xpath("e:nth-child(-2n+4)"), + "e[(count(preceding-sibling::*) <= 3) and ((count(preceding-sibling::*) +1) mod -2 = 0)]" + ); + // A negative step with a negative offset can never match. + assert_eq!(xpath("e:nth-child(-3n-2)"), "e[0]"); + assert_eq!( + xpath("e:nth-child(10n+5)"), + "e[(count(preceding-sibling::*) >= 4) and ((count(preceding-sibling::*) +6) mod 10 = 0)]" + ); + assert_eq!( + xpath("e:nth-child(2n-1)"), + "e[count(preceding-sibling::*) mod 2 = 0]" + ); +} + +#[test] +fn nth_child_minimum_index_does_not_panic() { + assert_eq!(xpath("p:nth-child(-9223372036854775808)"), "p[0]"); +} + +#[test] +fn nth_child_minimum_step_does_not_panic() { + assert_eq!( + xpath("p:nth-child(-9223372036854775808n+1)"), + "p[count(preceding-sibling::*) = 0]" + ); +} + +#[test] +fn suffix_match_unicode_offset() { + // The offset is the code-point length minus one, not the byte length. + assert_eq!( + xpath("e[foo$=\"é\"]"), + "e[@foo and substring(@foo, string-length(@foo)-0) = 'é']" + ); + assert_eq!( + xpath("e[foo$=\"ab\"]"), + "e[@foo and substring(@foo, string-length(@foo)-1) = 'ab']" + ); + assert_eq!( + xpath("e[foo$=\"résumé\"]"), + "e[@foo and substring(@foo, string-length(@foo)-5) = 'résumé']" + ); +} + +#[test] +fn generic_lang_output() { + // The generic translator emits the XPath `lang()` function. + assert_eq!(xpath(":lang(fr)"), "*[lang('fr')]"); + assert_eq!(xpath(":lang(\"en-US\")"), "*[lang('en-US')]"); +} + +#[test] +fn nth_last_child_family() { + assert_eq!( + xpath("e:nth-last-child(1)"), + "e[count(following-sibling::*) = 0]" + ); + assert_eq!( + xpath("e:nth-last-child(2n)"), + "e[(count(following-sibling::*) +1) mod 2 = 0]" + ); + assert_eq!( + xpath("e:nth-last-child(2n+1)"), + "e[count(following-sibling::*) mod 2 = 0]" + ); + assert_eq!( + xpath("e:nth-last-child(2n+2)"), + "e[(count(following-sibling::*) >= 1) and ((count(following-sibling::*) +1) mod 2 = 0)]" + ); + assert_eq!( + xpath("e:nth-last-child(3n+1)"), + "e[count(following-sibling::*) mod 3 = 0]" + ); + assert_eq!( + xpath("e:nth-last-child(-n+2)"), + "e[count(following-sibling::*) <= 1]" + ); +} + +#[test] +fn nth_of_type() { + assert_eq!( + xpath("e:nth-of-type(1)"), + "e[count(preceding-sibling::e) = 0]" + ); + assert_eq!( + xpath("e:nth-last-of-type(1)"), + "e[count(following-sibling::e) = 0]" + ); + assert_eq!( + xpath("div e:nth-last-of-type(1) .aclass"), + "div/descendant-or-self::*/e[count(following-sibling::e) = 0]\ +/descendant-or-self::*/*[@class and contains(\ +concat(' ', normalize-space(@class), ' '), ' aclass ')]" + ); +} + +#[test] +fn structural_pseudos() { + assert_eq!(xpath("e:first-child"), "e[count(preceding-sibling::*) = 0]"); + assert_eq!(xpath("e:last-child"), "e[count(following-sibling::*) = 0]"); + assert_eq!( + xpath("e:first-of-type"), + "e[count(preceding-sibling::e) = 0]" + ); + assert_eq!( + xpath("e:last-of-type"), + "e[count(following-sibling::e) = 0]" + ); + assert_eq!(xpath("e:only-child"), "e[count(parent::*/child::*) = 1]"); + assert_eq!(xpath("e:only-of-type"), "e[count(parent::*/child::e) = 1]"); + assert_eq!(xpath("e:empty"), "e[not(*) and not(string-length())]"); + assert_eq!(xpath("e:EmPTY"), "e[not(*) and not(string-length())]"); + assert_eq!(xpath("e:root"), "e[not(parent::*)]"); + assert_eq!(xpath("e:hover"), "e[0]"); +} + +#[test] +fn has_relations() { + assert_eq!( + xpath("div:has(bar.foo)"), + "div[descendant::bar[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]]" + ); + assert_eq!(xpath("e:has(> f)"), "e[./f]"); + assert_eq!( + xpath("e:has(> f.foo)"), + "e[./f[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]]" + ); + assert_eq!(xpath("e:has(f)"), "e[descendant::f]"); + assert_eq!(xpath("e:has(~ f)"), "e[following-sibling::f]"); + assert_eq!( + xpath("e:has(~ f.foo)"), + "e[following-sibling::f[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]]" + ); + assert_eq!( + xpath("e:has(+ f)"), + "e[following-sibling::*[(self::f) and (position() = 1)]]" + ); + assert_eq!( + xpath("e:has(+ f.foo)"), + "e[following-sibling::*[((@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')) and (self::f)) and (position() = 1)]]" + ); + assert_eq!( + xpath("e:has(+ .foo)"), + "e[following-sibling::*[(@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')) and (position() = 1)]]" + ); +} + +#[test] +fn contains_class_and_id() { + assert_eq!(xpath("e:contains(\"foo\")"), "e[contains(., 'foo')]"); + assert_eq!(xpath("e:ConTains(foo)"), "e[contains(., 'foo')]"); + assert_eq!( + xpath("e.warning"), + "e[@class and contains(concat(' ', normalize-space(@class), ' '), ' warning ')]" + ); + assert_eq!(xpath("e#myid"), "e[@id = 'myid']"); +} + +#[test] +fn negation() { + assert_eq!( + xpath("e:not(:nth-child(odd))"), + "e[not(count(preceding-sibling::*) mod 2 = 0)]" + ); + assert_eq!(xpath("e:nOT(*)"), "e[0]"); +} + +#[test] +fn combinators() { + assert_eq!(xpath("e f"), "e/descendant-or-self::*/f"); + assert_eq!(xpath("e > f"), "e/f"); + assert_eq!( + xpath("e + f"), + "e/following-sibling::*[(self::f) and (position() = 1)]" + ); + assert_eq!(xpath("e ~ f"), "e/following-sibling::f"); + assert_eq!( + xpath("e ~ f:nth-child(3)"), + "e/following-sibling::f[count(preceding-sibling::*) = 2]" + ); + assert_eq!( + xpath("div#container p"), + "div[@id = 'container']/descendant-or-self::*/p" + ); +} + +#[test] +fn where_matching() { + assert_eq!(xpath("e:where(foo)"), "e[self::foo]"); + assert_eq!(xpath("e:where(foo, bar)"), "e[(self::foo) or (self::bar)]"); +} + +#[test] +fn unsafe_xpath_names() { + assert_eq!(xpath(r"di\a0 v"), "*[name() = 'di\u{a0}v']"); + assert_eq!(xpath(r"di\[v"), "*[name() = 'di[v']"); + assert_eq!( + xpath(r"[h\a0 ref]"), + "*[attribute::*[name() = 'h\u{a0}ref']]" + ); + assert_eq!(xpath(r"[h\]ref]"), "*[attribute::*[name() = 'h]ref']]"); +} + +#[test] +fn expression_errors() { + err(":fİrst-child"); + err(":first-of-type"); + err(":only-of-type"); + err(":last-of-type"); + err(":nth-of-type(1)"); + err(":nth-last-of-type(1)"); + err(":nth-child(n-)"); + err(":after"); + err(":lorem-ipsum"); + err(":lorem(ipsum)"); + err("::lorem-ipsum"); +} + +#[test] +fn expression_error_text_echoes_tokens() { + // The three expression errors that quote their argument tokens must keep + // the token list character for character. + assert_eq!( + err_text(":nth-child(n-)"), + "Invalid series: '[<IDENT 'n-' at 11>]'" + ); + assert_eq!( + err_text(":contains(1)"), + "Expected a single string or ident for :contains(), got [<NUMBER '1' at 10>]" + ); + assert_eq!( + err_text(":lang(1)"), + "Expected a single string or ident for :lang(), got [<NUMBER '1' at 6>]" + ); +} + +#[test] +fn large_nth_child_coefficient_does_not_panic() { + // A coefficient too large for i64 must error, not overflow. + match GenericTranslator::new().css_to_xpath("p:nth-child(99999999999999999999n+1)") { + Err(SelectorError::Expression(_)) => {} + other => panic!("expected an expression error, got {other:?}"), + } +} + +#[test] +fn default_prefix() { + assert_eq!( + GenericTranslator::new().css_to_xpath("e").unwrap(), + "descendant-or-self::e" + ); +} diff --git a/browser/vendor/cssselect/tests/xpath_expr.rs b/browser/vendor/cssselect/tests/xpath_expr.rs new file mode 100644 index 000000000..005681afd --- /dev/null +++ b/browser/vendor/cssselect/tests/xpath_expr.rs @@ -0,0 +1,87 @@ +//! Direct `XpathExpr` building, `argument_types`, and pseudo-element repr. +//! +//! Custom pseudo-element handlers are not part of this crate's public API, so +//! the cases here cover the building blocks such handlers rely on: `join`, +//! `add_condition` precedence, and the functional pseudo-element accessors. + +use cssselect::{parse, xpath_literal, FunctionalPseudoElement, PseudoElement, XpathExpr}; + +#[test] +fn str_form_of_bare_condition() { + let expr = XpathExpr::new("", "", "@href"); + assert_eq!(expr.to_xpath(), "[@href]"); +} + +#[test] +fn add_condition_precedence() { + let mut expr = XpathExpr::new("", "*", "@id = 'first' or @id = 'second'"); + expr.add_condition("@href", "and"); + assert_eq!( + expr.to_xpath(), + "*[(@id = 'first' or @id = 'second') and (@href)]" + ); +} + +#[test] +fn join_builds_path() { + let mut left = XpathExpr::new("", "*", ""); + let other = XpathExpr::new("@href", "", ""); + left.join("/", &other); + assert_eq!(left.to_xpath(), "*/@href"); +} + +#[test] +fn literal_quoting() { + assert_eq!(xpath_literal("plain"), "'plain'"); + assert_eq!(xpath_literal("it's"), "\"it's\""); + assert_eq!(xpath_literal("a'b\"c"), "concat('a',\"'\",'b\"c')"); +} + +#[test] +fn functional_pseudo_element_argument_types() { + let cases: [(&str, &[&str]); 4] = [ + ("", &[]), + ("ident", &["IDENT"]), + ("\"string\"", &["STRING"]), + ("1", &["NUMBER"]), + ]; + for (arg, expected) in cases { + let css = build_pe(arg); + let selectors = parse(&css).unwrap(); + let pe = selectors[0].pseudo_element.as_ref().unwrap(); + let f = match pe { + PseudoElement::Functional(f) => f, + other => panic!("expected functional pseudo-element, got {other:?}"), + }; + assert_eq!(f.argument_types(), expected.to_vec(), "for arg {arg:?}"); + } +} + +/// Build `::pseudo_element(<arg>)`. +fn build_pe(arg: &str) -> String { + let mut s = String::from("::pseudo_element("); + s.push_str(arg); + s.push(')'); + s +} + +#[test] +fn functional_pseudo_element_canonical() { + let f = FunctionalPseudoElement::new("ATTR", parse("::x(name)").unwrap()[0].pseudo_args()); + assert_eq!(f.name, "attr"); + assert_eq!(f.canonical(), "attr(name)"); +} + +/// Helper to expose a parsed functional pseudo-element's arguments. +trait PseudoArgs { + fn pseudo_args(&self) -> Vec<cssselect::Token>; +} + +impl PseudoArgs for cssselect::Selector { + fn pseudo_args(&self) -> Vec<cssselect::Token> { + match &self.pseudo_element { + Some(PseudoElement::Functional(f)) => f.arguments.clone(), + _ => Vec::new(), + } + } +} diff --git a/browser/vendor/curl_impersonate_sys/.gitignore b/browser/vendor/curl_impersonate_sys/.gitignore new file mode 100644 index 000000000..a3f9dd866 --- /dev/null +++ b/browser/vendor/curl_impersonate_sys/.gitignore @@ -0,0 +1,3 @@ +/artifacts/ +/target/ +/Cargo.lock diff --git a/browser/vendor/curl_impersonate_sys/Cargo.toml b/browser/vendor/curl_impersonate_sys/Cargo.toml new file mode 100644 index 000000000..297ffd677 --- /dev/null +++ b/browser/vendor/curl_impersonate_sys/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "curl_impersonate_sys" +version = "0.1.0" +edition = "2021" +license = "MIT" +links = "curl-impersonate" +build = "build.rs" +publish = false + +[features] +default = [] +certified = [] + diff --git a/browser/vendor/curl_impersonate_sys/README.md b/browser/vendor/curl_impersonate_sys/README.md new file mode 100644 index 000000000..f3ee5e8e1 --- /dev/null +++ b/browser/vendor/curl_impersonate_sys/README.md @@ -0,0 +1,74 @@ +# curl_impersonate_sys + +Repository-owned, minimal raw Rust bindings for the native engine frozen by +curl-cffi 0.16.0. The browser worker links it only when its +`scrapling-compat` feature is explicitly enabled; normal safe builds retain +the artifact-free reqwest engine. + +## Frozen upstream + +- curl-cffi [`v0.16.0`](https://github.com/lexiforest/curl_cffi/releases/tag/v0.16.0) + says its packaged engine is curl 8.21 with curl-impersonate 2.0. +- Its tagged + [`Makefile`](https://github.com/lexiforest/curl_cffi/blob/v0.16.0/Makefile) + pins `VERSION := 2.0.0` and `CURL_VERSION := curl-8_21_0`. +- curl-impersonate [`v2.0.0`](https://github.com/lexiforest/curl-impersonate/releases/tag/v2.0.0) + publishes official `libcurl-impersonate` GNU/Linux archives for both Tier-1 + architectures. The release states that curl was updated to 8.21.0. +- The easy-handle surface follows curl's official + [libcurl easy interface](https://curl.se/libcurl/c/libcurl-easy.html). The + extra `curl_easy_impersonate` declaration comes from the v2.0.0 patched + `include/curl/easy.h` shipped in each verified archive. + +`artifacts.manifest` records the immutable GitHub release asset URLs, byte +lengths, and GitHub-published SHA-256 digests: + +| Rust target | Bytes | SHA-256 | +| --- | ---: | --- | +| `x86_64-unknown-linux-gnu` | 26,572,973 | `d8a98bc123fae4f04bb6a7584ff486333a334b3b08edaba1867929ae8d6ebb4d` | +| `aarch64-unknown-linux-gnu` | 25,595,976 | `12708019a6c1c3a7a7a40a8a379d12aaca127fcd13bda60b06fdb0e013f6433f` | + +The archives contain a monolithic `libcurl-impersonate.a`, the matching curl +headers, and shared-library variants. They therefore provide the frozen +BoringSSL/nghttp2/ngtcp2/nghttp3-enabled build instead of relying on ambient +system curl packages. + +## Artifact workflow + +Normal safe builds need no native artifact and do not link libcurl: + +```sh +cargo test --manifest-path vendor/curl_impersonate_sys/Cargo.toml +``` + +Artifact acquisition is explicit and separate from Cargo: + +```sh +scripts/fetch_curl_impersonate_artifacts.sh x86_64-unknown-linux-gnu +scripts/fetch_curl_impersonate_artifacts.sh --verify x86_64-unknown-linux-gnu +``` + +Set `CURL_IMPERSONATE_ARTIFACT_DIR` to use a shared CI/release cache. Its +layout is `<cache>/<target>/<archive>` plus `<cache>/<target>/root/`. + +A certified build never downloads. `build.rs` requires a supported Tier-1 +target, checks the archive byte length and SHA-256 again, checks the extracted +static library/header, then links the static archive. Missing, corrupt, or +unsupported inputs fail the build: + +```sh +cargo test --manifest-path vendor/curl_impersonate_sys/Cargo.toml \ + --features certified +``` + +The FFI is deliberately raw and small: global/easy lifecycle, option/info +varargs, list headers, impersonation, transfer callbacks, and the socket-open +callback used by the safe-mode egress gate. Higher-level request, ownership, +callback-unwind, and session policy belong in the integrating crate. + +## Licensing + +The bindings are MIT licensed. curl-impersonate's upstream MIT license is +included as `UPSTREAM_LICENSE`. Release artifacts also aggregate curl and its +native dependencies; a binary redistribution must preserve all notices from +the corresponding upstream source release. diff --git a/browser/vendor/curl_impersonate_sys/UPSTREAM_LICENSE b/browser/vendor/curl_impersonate_sys/UPSTREAM_LICENSE new file mode 100644 index 000000000..7475f58f6 --- /dev/null +++ b/browser/vendor/curl_impersonate_sys/UPSTREAM_LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 curl_cffi developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/browser/vendor/curl_impersonate_sys/artifacts.manifest b/browser/vendor/curl_impersonate_sys/artifacts.manifest new file mode 100644 index 000000000..8f820fab5 --- /dev/null +++ b/browser/vendor/curl_impersonate_sys/artifacts.manifest @@ -0,0 +1,3 @@ +# target|archive|bytes|sha256|official_url +x86_64-unknown-linux-gnu|libcurl-impersonate-v2.0.0.x86_64-linux-gnu.tar.gz|26572973|d8a98bc123fae4f04bb6a7584ff486333a334b3b08edaba1867929ae8d6ebb4d|https://github.com/lexiforest/curl-impersonate/releases/download/v2.0.0/libcurl-impersonate-v2.0.0.x86_64-linux-gnu.tar.gz +aarch64-unknown-linux-gnu|libcurl-impersonate-v2.0.0.aarch64-linux-gnu.tar.gz|25595976|12708019a6c1c3a7a7a40a8a379d12aaca127fcd13bda60b06fdb0e013f6433f|https://github.com/lexiforest/curl-impersonate/releases/download/v2.0.0/libcurl-impersonate-v2.0.0.aarch64-linux-gnu.tar.gz diff --git a/browser/vendor/curl_impersonate_sys/build.rs b/browser/vendor/curl_impersonate_sys/build.rs new file mode 100644 index 000000000..14c7f92ae --- /dev/null +++ b/browser/vendor/curl_impersonate_sys/build.rs @@ -0,0 +1,168 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const UNWINDER_SYMBOLS: &[&str] = &[ + "_Unwind_DeleteException", + "_Unwind_ForcedUnwind", + "_Unwind_GetGR", + "_Unwind_GetIP", + "_Unwind_GetLanguageSpecificData", + "_Unwind_GetRegionStart", + "_Unwind_RaiseException", + "_Unwind_Resume", + "_Unwind_SetGR", + "_Unwind_SetIP", + "__unw_add_dynamic_eh_frame_section", + "__unw_add_dynamic_fde", + "__unw_get_fpreg", + "__unw_get_proc_info", + "__unw_get_proc_name", + "__unw_get_reg", + "__unw_getcontext", + "__unw_init_local", + "__unw_is_fpreg", + "__unw_is_signal_frame", + "__unw_iterate_dwarf_unwind_cache", + "__unw_regname", + "__unw_remove_dynamic_eh_frame_section", + "__unw_remove_dynamic_fde", + "__unw_resume", + "__unw_set_fpreg", + "__unw_set_reg", + "__unw_step", + "__unw_step_stage2", +]; + +#[derive(Debug)] +struct Artifact<'a> { + target: &'a str, + archive: &'a str, + bytes: u64, + sha256: &'a str, +} + +fn artifact_for_target<'a>(manifest: &'a str, target: &str) -> Option<Artifact<'a>> { + manifest + .lines() + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .find_map(|line| { + let mut fields = line.split('|'); + let artifact = Artifact { + target: fields.next()?, + archive: fields.next()?, + bytes: fields.next()?.parse().ok()?, + sha256: fields.next()?, + }; + (artifact.target == target).then_some(artifact) + }) +} + +fn sha256(path: &Path) -> Result<String, String> { + let output = Command::new("sha256sum") + .arg(path) + .output() + .map_err(|error| format!("cannot run sha256sum: {error}"))?; + if !output.status.success() { + return Err(format!( + "sha256sum failed for {}: {}", + path.display(), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + String::from_utf8(output.stdout) + .map_err(|error| format!("sha256sum returned non-UTF-8 output: {error}"))? + .split_whitespace() + .next() + .map(str::to_owned) + .ok_or_else(|| "sha256sum returned empty output".to_string()) +} + +fn certified_artifact_dir(manifest_dir: &Path, target: &str) -> PathBuf { + env::var_os("CURL_IMPERSONATE_ARTIFACT_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| manifest_dir.join("artifacts")) + .join(target) +} + +fn main() { + println!("cargo:rustc-check-cfg=cfg(curl_impersonate_linked)"); + println!("cargo:rerun-if-changed=artifacts.manifest"); + println!("cargo:rerun-if-env-changed=CURL_IMPERSONATE_ARTIFACT_DIR"); + + // Safe/default builds compile the bindings and their tests without a + // native artifact. Certified compat builds explicitly opt into linking. + if env::var_os("CARGO_FEATURE_CERTIFIED").is_none() { + return; + } + + let target = env::var("TARGET").expect("Cargo always sets TARGET"); + let manifest = include_str!("artifacts.manifest"); + let artifact = artifact_for_target(manifest, &target).unwrap_or_else(|| { + panic!( + "curl-impersonate certified mode is unsupported for target {target}; \ + supported targets are x86_64-unknown-linux-gnu and aarch64-unknown-linux-gnu" + ) + }); + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let artifact_dir = certified_artifact_dir(&manifest_dir, &target); + let archive = artifact_dir.join(artifact.archive); + let root = artifact_dir.join("root"); + let library = root.join("libcurl-impersonate.a"); + let header = root.join("include/curl/curl.h"); + + let metadata = fs::metadata(&archive).unwrap_or_else(|_| { + panic!( + "certified curl-impersonate artifact is absent: {}; run scripts/fetch_curl_impersonate_artifacts.sh {target}", + archive.display() + ) + }); + assert_eq!( + metadata.len(), + artifact.bytes, + "certified curl-impersonate artifact has wrong byte length: {}", + archive.display() + ); + let actual = sha256(&archive).unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + actual, + artifact.sha256, + "certified curl-impersonate artifact checksum mismatch: {}", + archive.display() + ); + assert!( + library.is_file() && header.is_file(), + "certified artifact was verified but not extracted under {}; run the artifact fetch script", + root.display() + ); + + // The official monolithic static archive includes LLVM libunwind. Leaving + // those symbols global replaces Rust's process unwinder and makes error + // backtraces segfault. Keep curl's internal references local while letting + // the binary use its normal libgcc unwinder. + let link_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap()).join("native"); + fs::create_dir_all(&link_dir).expect("create native link directory"); + let sanitized = link_dir.join("libcurl-impersonate.a"); + fs::copy(&library, &sanitized).expect("copy verified curl archive"); + let mut objcopy = Command::new("objcopy"); + for symbol in UNWINDER_SYMBOLS { + objcopy.arg(format!("--localize-symbol={symbol}")); + } + let output = objcopy + .arg(&sanitized) + .output() + .expect("certified builds require GNU objcopy"); + assert!( + output.status.success(), + "objcopy failed to isolate curl's bundled unwinder: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + + println!("cargo:rustc-link-search=native={}", link_dir.display()); + println!("cargo:rustc-link-lib=static=curl-impersonate"); + println!("cargo:rustc-link-lib=pthread"); + println!("cargo:rustc-link-lib=dl"); + println!("cargo:rustc-link-lib=m"); + println!("cargo:rustc-cfg=curl_impersonate_linked"); +} diff --git a/browser/vendor/curl_impersonate_sys/src/lib.rs b/browser/vendor/curl_impersonate_sys/src/lib.rs new file mode 100644 index 000000000..2c87d5dcf --- /dev/null +++ b/browser/vendor/curl_impersonate_sys/src/lib.rs @@ -0,0 +1,202 @@ +//! Minimal raw bindings for the frozen curl-cffi 0.16.0 native engine. +//! +//! The crate does not download at build time. Enable `certified` only after +//! populating the verified Tier-1 artifact cache described in the README. + +#![allow(non_camel_case_types)] + +use std::ffi::{c_char, c_int, c_long, c_uint, c_void}; + +pub const CURL_CFFI_VERSION: &str = "0.16.0"; +pub const CURL_IMPERSONATE_VERSION: &str = "2.0.0"; +pub const LIBCURL_VERSION: &str = "8.21.0-IMPERSONATE"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Artifact<'a> { + pub target: &'a str, + pub archive: &'a str, + pub bytes: u64, + pub sha256: &'a str, + pub url: &'a str, +} + +pub fn artifact_for_target(target: &str) -> Option<Artifact<'static>> { + include_str!("../artifacts.manifest") + .lines() + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .find_map(|line| { + let mut fields = line.split('|'); + let artifact = Artifact { + target: fields.next()?, + archive: fields.next()?, + bytes: fields.next()?.parse().ok()?, + sha256: fields.next()?, + url: fields.next()?, + }; + (artifact.target == target).then_some(artifact) + }) +} + +pub type CURL = c_void; +pub type CURLcode = c_uint; +pub type CURLoption = c_uint; +pub type CURLINFO = c_uint; +pub type curl_socket_t = c_int; +pub type curl_off_t = i64; + +#[repr(C)] +pub struct curl_slist { + pub data: *mut c_char, + pub next: *mut curl_slist, +} + +#[repr(C)] +pub struct curl_sockaddr { + pub family: c_int, + pub socktype: c_int, + pub protocol: c_int, + pub addrlen: c_uint, + pub addr: sockaddr, +} + +// Linux `struct sockaddr`; `curl_sockaddr.addrlen` says how many bytes are +// valid when a callback views `addr` as sockaddr_in/sockaddr_in6. +#[repr(C)] +pub struct sockaddr { + pub sa_family: u16, + pub sa_data: [u8; 14], +} + +pub type curl_write_callback = + Option<unsafe extern "C" fn(*mut c_char, usize, usize, *mut c_void) -> usize>; +pub type curl_read_callback = + Option<unsafe extern "C" fn(*mut c_char, usize, usize, *mut c_void) -> usize>; +pub type curl_opensocket_callback = + Option<unsafe extern "C" fn(*mut c_void, c_int, *mut curl_sockaddr) -> curl_socket_t>; + +pub const CURLE_OK: CURLcode = 0; +pub const CURL_GLOBAL_SSL: c_long = 1; +pub const CURL_GLOBAL_WIN32: c_long = 2; +pub const CURL_GLOBAL_DEFAULT: c_long = CURL_GLOBAL_SSL | CURL_GLOBAL_WIN32; +pub const CURL_SOCKET_BAD: curl_socket_t = -1; +pub const CURLAUTH_BASIC: c_long = 1; +pub const CURL_HTTP_VERSION_3ONLY: c_long = 31; +pub const CURLFOLLOW_SAFE: c_long = 4; + +pub const CURLOPT_WRITEDATA: CURLoption = 10_001; +pub const CURLOPT_URL: CURLoption = 10_002; +pub const CURLOPT_PROXY: CURLoption = 10_004; +pub const CURLOPT_USERPWD: CURLoption = 10_005; +pub const CURLOPT_PROXYUSERPWD: CURLoption = 10_006; +pub const CURLOPT_READDATA: CURLoption = 10_009; +pub const CURLOPT_ERRORBUFFER: CURLoption = 10_010; +pub const CURLOPT_WRITEFUNCTION: CURLoption = 20_011; +pub const CURLOPT_READFUNCTION: CURLoption = 20_012; +pub const CURLOPT_TIMEOUT: CURLoption = 13; +pub const CURLOPT_POSTFIELDS: CURLoption = 10_015; +pub const CURLOPT_REFERER: CURLoption = 10_016; +pub const CURLOPT_USERAGENT: CURLoption = 10_018; +pub const CURLOPT_COOKIE: CURLoption = 10_022; +pub const CURLOPT_HTTPHEADER: CURLoption = 10_023; +pub const CURLOPT_HEADERDATA: CURLoption = 10_029; +pub const CURLOPT_COOKIEFILE: CURLoption = 10_031; +pub const CURLOPT_CUSTOMREQUEST: CURLoption = 10_036; +pub const CURLOPT_NOBODY: CURLoption = 44; +pub const CURLOPT_POST: CURLoption = 47; +pub const CURLOPT_FOLLOWLOCATION: CURLoption = 52; +pub const CURLOPT_POSTFIELDSIZE: CURLoption = 60; +pub const CURLOPT_SSL_VERIFYPEER: CURLoption = 64; +pub const CURLOPT_MAXREDIRS: CURLoption = 68; +pub const CURLOPT_CONNECTTIMEOUT: CURLoption = 78; +pub const CURLOPT_HEADERFUNCTION: CURLoption = 20_079; +pub const CURLOPT_HTTPGET: CURLoption = 80; +pub const CURLOPT_SSL_VERIFYHOST: CURLoption = 81; +pub const CURLOPT_COOKIEJAR: CURLoption = 10_082; +pub const CURLOPT_HTTP_VERSION: CURLoption = 84; +pub const CURLOPT_NOSIGNAL: CURLoption = 99; +pub const CURLOPT_ACCEPT_ENCODING: CURLoption = 10_102; +pub const CURLOPT_PRIVATE: CURLoption = 10_103; +pub const CURLOPT_HTTPAUTH: CURLoption = 107; +pub const CURLOPT_PROXYAUTH: CURLoption = 111; +pub const CURLOPT_COOKIELIST: CURLoption = 10_135; +pub const CURLOPT_TIMEOUT_MS: CURLoption = 155; +pub const CURLOPT_CONNECTTIMEOUT_MS: CURLoption = 156; +pub const CURLOPT_OPENSOCKETFUNCTION: CURLoption = 20_163; +pub const CURLOPT_OPENSOCKETDATA: CURLoption = 10_164; + +pub const CURLINFO_EFFECTIVE_URL: CURLINFO = 0x10_0001; +pub const CURLINFO_RESPONSE_CODE: CURLINFO = 0x20_0002; +pub const CURLINFO_TOTAL_TIME: CURLINFO = 0x30_0003; +pub const CURLINFO_CONTENT_TYPE: CURLINFO = 0x10_0012; +pub const CURLINFO_REDIRECT_COUNT: CURLINFO = 0x20_0014; +pub const CURLINFO_REDIRECT_URL: CURLINFO = 0x10_001f; +/// curl-cffi's patched libcurl reports accepted cookie mutations here. +pub const CURLINFO_COOKIECHANGES: CURLINFO = 0x40_03e8; + +#[cfg(curl_impersonate_linked)] +extern "C" { + pub fn curl_global_init(flags: c_long) -> CURLcode; + pub fn curl_global_cleanup(); + pub fn curl_version() -> *const c_char; + pub fn curl_easy_init() -> *mut CURL; + pub fn curl_easy_duphandle(curl: *mut CURL) -> *mut CURL; + pub fn curl_easy_cleanup(curl: *mut CURL); + pub fn curl_easy_reset(curl: *mut CURL); + pub fn curl_easy_perform(curl: *mut CURL) -> CURLcode; + pub fn curl_easy_setopt(curl: *mut CURL, option: CURLoption, ...) -> CURLcode; + pub fn curl_easy_getinfo(curl: *mut CURL, info: CURLINFO, ...) -> CURLcode; + pub fn curl_easy_strerror(code: CURLcode) -> *const c_char; + pub fn curl_easy_impersonate( + curl: *mut CURL, + target: *const c_char, + default_headers: c_int, + ) -> CURLcode; + pub fn curl_slist_append(list: *mut curl_slist, value: *const c_char) -> *mut curl_slist; + pub fn curl_slist_free_all(list: *mut curl_slist); +} + +#[cfg(all(test, feature = "certified", curl_impersonate_linked))] +mod linked_tests { + use super::*; + use std::ffi::{CStr, CString}; + + #[test] + fn linked_archive_reports_the_frozen_libcurl() { + let version = unsafe { CStr::from_ptr(curl_version()) }.to_str().unwrap(); + assert!( + version.starts_with("libcurl/8.21.0-IMPERSONATE "), + "{version}" + ); + for component in [ + "BoringSSL", + "nghttp2/1.63.0", + "ngtcp2/1.20.0", + "nghttp3/1.15.0", + ] { + assert!( + version.contains(component), + "missing {component}: {version}" + ); + } + } + + #[test] + fn linked_archive_exports_easy_impersonation() { + unsafe { + assert_eq!(curl_global_init(CURL_GLOBAL_DEFAULT), CURLE_OK); + let easy = curl_easy_init(); + assert!(!easy.is_null()); + let target = CString::new("chrome136").unwrap(); + let result = curl_easy_impersonate(easy, target.as_ptr(), 1); + curl_easy_cleanup(easy); + curl_global_cleanup(); + assert_eq!(result, CURLE_OK); + } + } + + #[test] + fn linked_archive_does_not_replace_the_process_unwinder() { + let trace = std::backtrace::Backtrace::force_capture().to_string(); + assert!(!trace.is_empty()); + } +} diff --git a/browser/vendor/curl_impersonate_sys/tests/manifest.rs b/browser/vendor/curl_impersonate_sys/tests/manifest.rs new file mode 100644 index 000000000..16fdf55a2 --- /dev/null +++ b/browser/vendor/curl_impersonate_sys/tests/manifest.rs @@ -0,0 +1,34 @@ +use curl_impersonate_sys::{artifact_for_target, CURL_CFFI_VERSION, CURL_IMPERSONATE_VERSION}; + +#[test] +fn frozen_engine_and_tier_one_artifacts_are_exact() { + assert_eq!(CURL_CFFI_VERSION, "0.16.0"); + assert_eq!(CURL_IMPERSONATE_VERSION, "2.0.0"); + + let x86 = artifact_for_target("x86_64-unknown-linux-gnu").unwrap(); + assert_eq!(x86.bytes, 26_572_973); + assert_eq!( + x86.sha256, + "d8a98bc123fae4f04bb6a7584ff486333a334b3b08edaba1867929ae8d6ebb4d" + ); + + let arm = artifact_for_target("aarch64-unknown-linux-gnu").unwrap(); + assert_eq!(arm.bytes, 25_595_976); + assert_eq!( + arm.sha256, + "12708019a6c1c3a7a7a40a8a379d12aaca127fcd13bda60b06fdb0e013f6433f" + ); + + assert!(artifact_for_target("x86_64-unknown-linux-musl").is_none()); +} + +#[test] +fn ffi_constants_match_the_frozen_header() { + use curl_impersonate_sys::*; + + assert_eq!(CURLOPT_URL, 10_002); + assert_eq!(CURLOPT_WRITEFUNCTION, 20_011); + assert_eq!(CURLOPT_FOLLOWLOCATION, 52); + assert_eq!(CURLINFO_RESPONSE_CODE, 0x20_0002); + assert_eq!(CURL_GLOBAL_DEFAULT, 3); +} diff --git a/browser/vendor/markdownify-1.2.3-beautifulsoup-4.15.0.NOTICE b/browser/vendor/markdownify-1.2.3-beautifulsoup-4.15.0.NOTICE new file mode 100644 index 000000000..1708bb570 --- /dev/null +++ b/browser/vendor/markdownify-1.2.3-beautifulsoup-4.15.0.NOTICE @@ -0,0 +1,59 @@ +The compatibility implementation in src/scrapling/markdown.rs is derived from +markdownify 1.2.3 and reproduces the observable tree behavior of Beautiful +Soup 4.15.0's html.parser builder. + +Frozen source hashes: + +- markdownify/__init__.py: 281bca0e2ab84a21825376f82d07d95455f41a6b0392bf95950bf29aac540b40 +- bs4/__init__.py: 90e007f82a9fc5bcf22a8df3b395b7bc5245e54d65cd68fd51ca12a414718d4e + +markdownify license +------------------- + +The MIT License (MIT) + +Copyright 2012-2018 Matthew Tretter + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Beautiful Soup license +---------------------- + +Copyright (c) Leonard Richardson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Beautiful Soup incorporates html5lib code, also under the MIT license, +Copyright (c) James Graham and other contributors. diff --git a/browser/vendor/rustpython-sre_engine/Cargo.lock b/browser/vendor/rustpython-sre_engine/Cargo.lock new file mode 100644 index 000000000..17984f680 --- /dev/null +++ b/browser/vendor/rustpython-sre_engine/Cargo.lock @@ -0,0 +1,328 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "optional" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978aa494585d3ca4ad74929863093e87cac9790d81fe7aba2b3dc2890643a0fc" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" + +[[package]] +name = "rustpython-sre_engine" +version = "0.5.0" +dependencies = [ + "bitflags", + "num_enum", + "optional", + "rustpython-wtf8", + "unicode-ident", + "unicode_names2", +] + +[[package]] +name = "rustpython-wtf8" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada88d2f69ff5516d69e0f3294e9db2ff8ee71a15291b8f3f8584f07ad1ca28d" +dependencies = [ + "ascii", + "bstr", + "itertools", + "memchr", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode_names2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d189085656ca1203291e965444e7f6a2723fbdd1dd9f34f8482e79bafd8338a0" +dependencies = [ + "phf", + "unicode_names2_generator", +] + +[[package]] +name = "unicode_names2_generator" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1262662dc96937c71115228ce2e1d30f41db71a7a45d3459e98783ef94052214" +dependencies = [ + "phf_codegen", + "rand", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/browser/vendor/rustpython-sre_engine/Cargo.toml b/browser/vendor/rustpython-sre_engine/Cargo.toml new file mode 100644 index 000000000..c598db0a1 --- /dev/null +++ b/browser/vendor/rustpython-sre_engine/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "rustpython-sre_engine" +version = "0.5.0" +edition = "2024" +rust-version = "1.93" +license = "MIT" +repository = "https://github.com/RustPython/RustPython" +description = "Repository-owned Scrapling compatibility fork of RustPython's SRE engine" + +[dependencies] +bitflags = "2.11" +num_enum = { version = "0.7", default-features = false } +optional = "0.5" +rustpython-wtf8 = "0.5.0" +unicode-ident = "1" +unicode_names2 = "2" diff --git a/browser/vendor/rustpython-sre_engine/Cargo.toml.orig b/browser/vendor/rustpython-sre_engine/Cargo.toml.orig new file mode 100644 index 000000000..4f899e6b3 --- /dev/null +++ b/browser/vendor/rustpython-sre_engine/Cargo.toml.orig @@ -0,0 +1,27 @@ +[package] +name = "rustpython-sre_engine" +authors = ["Kangzhi Shi <shikangzhi@gmail.com>", "RustPython Team"] +description = "A low-level implementation of Python's SRE regex engine" +keywords = ["regex"] +include = ["LICENSE", "src/**/*.rs"] +version.workspace = true +edition.workspace = true +rust-version.workspace = true +repository.workspace = true +license.workspace = true + +[[bench]] +name = "benches" +harness = false + +[dependencies] +rustpython-wtf8 = { workspace = true } +num_enum = { workspace = true } +bitflags = { workspace = true } +optional = { workspace = true } + +[dev-dependencies] +criterion = { workspace = true } + +[lints] +workspace = true diff --git a/browser/vendor/rustpython-sre_engine/LICENSE b/browser/vendor/rustpython-sre_engine/LICENSE new file mode 100644 index 000000000..e2aa2ed95 --- /dev/null +++ b/browser/vendor/rustpython-sre_engine/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 RustPython Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/browser/vendor/rustpython-sre_engine/README.md b/browser/vendor/rustpython-sre_engine/README.md new file mode 100644 index 000000000..e09f7bfc6 --- /dev/null +++ b/browser/vendor/rustpython-sre_engine/README.md @@ -0,0 +1,8 @@ +# rustpython-sre_engine compatibility fork + +Forked from `rustpython-sre_engine` 0.5.0 for the browser worker's frozen +CPython 3.12 regular-expression contract. Upstream source is MIT licensed; +see `LICENSE`. + +The local `compiler` module is the worker-owned Rust port of the matching +parts of CPython 3.12's `Lib/re/_parser.py` and `Lib/re/_compiler.py`. diff --git a/browser/vendor/rustpython-sre_engine/examples/differential_driver.rs b/browser/vendor/rustpython-sre_engine/examples/differential_driver.rs new file mode 100644 index 000000000..11bf4b5ff --- /dev/null +++ b/browser/vendor/rustpython-sre_engine/examples/differential_driver.rs @@ -0,0 +1,89 @@ +use std::io::{self, BufRead, Write}; + +use rustpython_sre_engine::compiler; +use rustpython_sre_engine::{Request, SearchIter, State, StrDrive}; + +fn decode_hex(value: &str) -> Result<String, String> { + if !value.len().is_multiple_of(2) { + return Err("odd hex input".to_string()); + } + let bytes = (0..value.len()) + .step_by(2) + .map(|index| u8::from_str_radix(&value[index..index + 2], 16)) + .collect::<Result<Vec<_>, _>>() + .map_err(|error| error.to_string())?; + String::from_utf8(bytes).map_err(|error| error.to_string()) +} + +fn encode_hex(value: &str) -> String { + value + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn findall(pattern: &str, text: &str, ignore_case: bool) -> Result<Vec<Vec<String>>, String> { + let compiled = compiler::compile(pattern, ignore_case).map_err(|error| error.to_string())?; + let req = Request::new(text, 0, text.count(), &compiled.codes, false); + let mut iter = SearchIter { + req, + state: State::default(), + }; + let chars: Vec<char> = text.chars().collect(); + let mut matches = Vec::new(); + while iter.next().is_some() { + let mut groups = Vec::new(); + if compiled.groups == 0 { + groups.push( + chars[iter.state.start..iter.state.cursor.position] + .iter() + .collect(), + ); + } else { + for group in 0..compiled.groups { + let (start, end) = iter.state.marks.get(group); + groups.push(match (start.into_option(), end.into_option()) { + (Some(start), Some(end)) => chars[start..end].iter().collect(), + _ => String::new(), + }); + } + } + matches.push(groups); + } + Ok(matches) +} + +fn encode_matches(matches: &[Vec<String>]) -> String { + let mut encoded = matches.len().to_string(); + for groups in matches { + encoded.push('\t'); + encoded.push_str(&groups.len().to_string()); + for group in groups { + encoded.push(':'); + encoded.push_str(&encode_hex(group)); + } + } + encoded +} + +fn main() { + let stdin = io::stdin(); + let mut stdout = io::BufWriter::new(io::stdout().lock()); + for line in stdin.lock().lines() { + let line = line.expect("stdin"); + let mut fields = line.split('\t'); + let ignore_case = fields.next() == Some("1"); + let pattern = fields.next().and_then(|value| decode_hex(value).ok()); + let text = fields.next().and_then(|value| decode_hex(value).ok()); + let result = match (pattern, text) { + (Some(pattern), Some(text)) => match findall(&pattern, &text, ignore_case) { + Ok(matches) => format!("OK\t{}", encode_matches(&matches)), + Err(error) => format!("ERR\t{}", encode_hex(&error)), + }, + _ => format!("ERR\t{}", encode_hex("invalid differential input")), + }; + writeln!(stdout, "{result}").expect("stdout"); + stdout.flush().expect("stdout flush"); + } +} diff --git a/browser/vendor/rustpython-sre_engine/src/compiler.rs b/browser/vendor/rustpython-sre_engine/src/compiler.rs new file mode 100644 index 000000000..d438b5e0b --- /dev/null +++ b/browser/vendor/rustpython-sre_engine/src/compiler.rs @@ -0,0 +1,1645 @@ +//! CPython 3.12 `re` parser/compiler compatibility layer. +//! +//! The matcher in this crate consumes CPython SRE bytecode. Upstream leaves +//! parsing and compilation to Python's `Lib/re`; this module ports that +//! observable subset directly so the browser worker does not embed Python. + +use crate::{MAXGROUPS, MAXREPEAT, SreAtCode, SreCatCode, SreOpcode}; +use alloc::{ + collections::BTreeMap, + format, + string::{String, ToString}, + vec, + vec::Vec, +}; +use core::fmt; + +const FLAG_TEMPLATE: u16 = 1; +const FLAG_IGNORECASE: u16 = 2; +const FLAG_LOCALE: u16 = 4; +const FLAG_MULTILINE: u16 = 8; +const FLAG_DOTALL: u16 = 16; +const FLAG_UNICODE: u16 = 32; +const FLAG_VERBOSE: u16 = 64; +const FLAG_DEBUG: u16 = 128; +const FLAG_ASCII: u16 = 256; +const TYPE_FLAGS: u16 = FLAG_ASCII | FLAG_LOCALE | FLAG_UNICODE; +const GLOBAL_FLAGS: u16 = FLAG_DEBUG | FLAG_TEMPLATE; +const MAX_WIDTH: u64 = u64::MAX; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Error { + message: String, + position: Option<usize>, +} + +impl Error { + fn at(message: impl Into<String>, position: usize) -> Self { + Self { + message: message.into(), + position: Some(position), + } + } + + fn plain(message: impl Into<String>) -> Self { + Self { + message: message.into(), + position: None, + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message)?; + if let Some(position) = self.position { + write!(f, " at position {position}")?; + } + Ok(()) + } +} + +#[derive(Clone, Debug)] +pub struct Compiled { + pub codes: Vec<u32>, + pub groups: usize, +} + +#[derive(Clone, Debug, PartialEq)] +enum Op { + Literal(u32), + NotLiteral(u32), + Set(Vec<SetOp>), + Any, + Repeat { + kind: RepeatKind, + min: usize, + max: usize, + body: Pattern, + }, + Subpattern { + group: Option<usize>, + add_flags: u16, + del_flags: u16, + body: Pattern, + }, + Atomic(Pattern), + Assert { + negative: bool, + behind: bool, + body: Pattern, + }, + At(u32), + Branch(Vec<Pattern>), + GroupRef(usize), + GroupRefExists { + group: usize, + yes: Pattern, + no: Option<Pattern>, + }, +} + +type Pattern = Vec<Op>; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RepeatKind { + Greedy, + Lazy, + Possessive, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum SetOp { + Literal(u32), + Range(u32, u32), + Category(u32), + Negate, +} + +#[derive(Default)] +struct ParseState { + flags: u16, + group_names: BTreeMap<String, usize>, + group_widths: Vec<Option<(u64, u64)>>, + lookbehind_groups: Option<usize>, + forward_group_refs: BTreeMap<usize, usize>, +} + +impl ParseState { + fn new(flags: u16) -> Self { + Self { + flags, + group_widths: vec![None], + ..Self::default() + } + } + + fn open_group(&mut self, name: Option<String>) -> Result<usize, Error> { + let group = self.group_widths.len(); + self.group_widths.push(None); + if self.group_widths.len() > MAXGROUPS { + return Err(Error::plain("too many groups")); + } + if let Some(name) = name { + if let Some(previous) = self.group_names.get(&name) { + return Err(Error::plain(format!( + "redefinition of group name '{name}' as group {group}; was group {previous}" + ))); + } + self.group_names.insert(name, group); + } + Ok(group) + } + + fn group_is_closed(&self, group: usize) -> bool { + self.group_widths.get(group).is_some_and(Option::is_some) + } + + fn check_lookbehind_group(&self, group: usize, position: usize) -> Result<(), Error> { + if let Some(first_lookbehind_group) = self.lookbehind_groups { + if !self.group_is_closed(group) { + return Err(Error::at("cannot refer to an open group", position)); + } + if group >= first_lookbehind_group { + return Err(Error::at( + "cannot refer to group defined in the same lookbehind subpattern", + position, + )); + } + } + Ok(()) + } +} + +#[derive(Clone)] +struct Tokenizer { + chars: Vec<char>, + position: usize, +} + +impl Tokenizer { + fn new(pattern: &str) -> Self { + Self { + chars: pattern.chars().collect(), + position: 0, + } + } + + fn token(&self) -> Result<Option<String>, Error> { + let Some(&ch) = self.chars.get(self.position) else { + return Ok(None); + }; + if ch != '\\' { + return Ok(Some(ch.into())); + } + let Some(&escaped) = self.chars.get(self.position + 1) else { + return Err(Error::at("bad escape (end of pattern)", self.position)); + }; + Ok(Some(format!("\\{escaped}"))) + } + + fn get(&mut self) -> Result<Option<String>, Error> { + let token = self.token()?; + if let Some(token) = &token { + self.position += token.chars().count(); + } + Ok(token) + } + + fn matches(&mut self, expected: &str) -> Result<bool, Error> { + if self.token()?.as_deref() == Some(expected) { + self.get()?; + Ok(true) + } else { + Ok(false) + } + } + + fn tell(&self) -> usize { + self.position + } + + fn seek(&mut self, position: usize) { + self.position = position; + } + + fn get_while(&mut self, limit: usize, allowed: fn(char) -> bool) -> Result<String, Error> { + let mut out = String::new(); + for _ in 0..limit { + let Some(token) = self.token()? else { break }; + let mut chars = token.chars(); + let ch = chars.next().expect("nonempty token"); + if chars.next().is_some() || !allowed(ch) { + break; + } + out.push(ch); + self.get()?; + } + Ok(out) + } + + fn get_until(&mut self, terminator: &str, name: &str) -> Result<String, Error> { + let mut out = String::new(); + loop { + let position = self.tell(); + let Some(token) = self.get()? else { + let message = if out.is_empty() { + format!("missing {name}") + } else { + format!("missing {terminator}, unterminated name") + }; + return Err(Error::at( + message, + position.saturating_sub(out.chars().count()), + )); + }; + if token == terminator { + if out.is_empty() { + return Err(Error::at(format!("missing {name}"), position)); + } + return Ok(out); + } + out.push_str(&token); + } + } +} + +pub fn compile(pattern: &str, case_insensitive: bool) -> Result<Compiled, Error> { + let flags = if case_insensitive { FLAG_IGNORECASE } else { 0 }; + let mut parser = Parser { + source: Tokenizer::new(pattern), + state: ParseState::new(flags), + }; + let parsed = parser.parse_sub(false, 0)?; + parser.state.flags = fix_flags(parser.state.flags)?; + if parser.source.token()?.is_some() { + return Err(Error::at("unbalanced parenthesis", parser.source.tell())); + } + for (&group, &position) in &parser.state.forward_group_refs { + if group >= parser.state.group_widths.len() { + return Err(Error::at( + format!("invalid group reference {group}"), + position, + )); + } + } + + let mut codes = Vec::new(); + let (min, max) = width(&parsed, &parser.state); + emit(&mut codes, SreOpcode::INFO); + let info_skip = codes.len(); + codes.extend([ + 0, + 0, + min.min(u32::MAX as u64) as u32, + max.min(u32::MAX as u64) as u32, + ]); + if min != 0 + && let Some(set) = first_charset(&parsed) + { + codes[info_skip + 1] = crate::SreInfo::CHARSET.bits(); + let mut compiled_set = Vec::new(); + compile_set(set, parser.state.flags, &mut compiled_set); + codes.extend_from_slice(&compiled_set[2..]); + } + codes[info_skip] = (codes.len() - info_skip) as u32; + compile_pattern(&parsed, parser.state.flags, &parser.state, &mut codes)?; + emit(&mut codes, SreOpcode::SUCCESS); + Ok(Compiled { + codes, + groups: parser.state.group_widths.len() - 1, + }) +} + +struct Parser { + source: Tokenizer, + state: ParseState, +} + +impl Parser { + fn error(&self, message: impl Into<String>, offset: usize) -> Error { + Error::at(message, self.source.tell().saturating_sub(offset)) + } + + fn parse_sub(&mut self, verbose: bool, nested: usize) -> Result<Pattern, Error> { + let mut branches = Vec::new(); + loop { + let first = nested == 0 && branches.is_empty(); + branches.push(self.parse_sequence(verbose, nested + 1, first)?); + if !self.source.matches("|")? { + break; + } + } + if branches.len() == 1 { + Ok(branches.pop().expect("one branch")) + } else { + Ok(vec![Op::Branch(branches)]) + } + } + + fn parse_sequence( + &mut self, + mut verbose: bool, + nested: usize, + first: bool, + ) -> Result<Pattern, Error> { + let mut out = Vec::new(); + while let Some(token) = self.source.token()? { + if token == "|" || token == ")" { + break; + } + self.source.get()?; + + if verbose { + let ch = one_char(&token); + if ch.is_some_and(is_verbose_whitespace) { + continue; + } + if token == "#" { + loop { + match self.source.get()? { + None => break, + Some(value) if value == "\n" => break, + _ => {} + } + } + continue; + } + } + + if token.starts_with('\\') { + out.push(self.parse_escape(&token, false)?); + continue; + } + if !".\\[{()*+?^$|".contains(&token) { + out.push(Op::Literal(one_char(&token).expect("literal") as u32)); + continue; + } + match token.as_str() { + "[" => out.push(self.parse_set()?), + "*" | "+" | "?" | "{" => self.parse_repeat(&mut out, &token)?, + "." => out.push(Op::Any), + "(" => { + if let Some(op) = self.parse_group(&out, &mut verbose, nested, first)? { + out.push(op); + } + } + "^" => out.push(Op::At(SreAtCode::BEGINNING as u32)), + "$" => out.push(Op::At(SreAtCode::END as u32)), + _ => unreachable!("special token {token}"), + } + } + Ok(out) + } + + fn parse_set(&mut self) -> Result<Op, Error> { + let start = self.source.tell() - 1; + let negate = self.source.matches("^")?; + let mut items = Vec::new(); + loop { + let Some(token) = self.source.get()? else { + return Err(Error::at("unterminated character set", start)); + }; + if token == "]" && !items.is_empty() { + break; + } + let first = self.parse_set_atom(&token)?; + if self.source.matches("-")? { + let Some(second_token) = self.source.get()? else { + return Err(Error::at("unterminated character set", start)); + }; + if second_token == "]" { + items.push(first); + items.push(SetOp::Literal('-' as u32)); + break; + } + let second = self.parse_set_atom(&second_token)?; + let (SetOp::Literal(low), SetOp::Literal(high)) = (&first, &second) else { + return Err(self.error( + format!("bad character range {token}-{second_token}"), + token.chars().count() + 1 + second_token.chars().count(), + )); + }; + if high < low { + return Err(self.error( + format!("bad character range {token}-{second_token}"), + token.chars().count() + 1 + second_token.chars().count(), + )); + } + items.push(SetOp::Range(*low, *high)); + } else { + items.push(first); + } + } + dedup_set(&mut items); + if items.len() == 1 + && let SetOp::Literal(value) = items[0] + { + return Ok(if negate { + Op::NotLiteral(value) + } else { + Op::Literal(value) + }); + } + if negate { + items.insert(0, SetOp::Negate); + } + Ok(Op::Set(items)) + } + + fn parse_set_atom(&mut self, token: &str) -> Result<SetOp, Error> { + if token.starts_with('\\') { + match self.parse_escape(token, true)? { + Op::Literal(value) => Ok(SetOp::Literal(value)), + Op::Set(mut values) if values.len() == 1 => Ok(values.remove(0)), + _ => unreachable!("class escape shape"), + } + } else { + Ok(SetOp::Literal(one_char(token).expect("set token") as u32)) + } + } + + fn parse_repeat(&mut self, out: &mut Pattern, token: &str) -> Result<(), Error> { + let here = self.source.tell(); + let (mut min, mut max) = match token { + "?" => (0, 1), + "*" => (0, MAXREPEAT), + "+" => (1, MAXREPEAT), + "{" => { + if self.source.matches("}")? { + out.push(Op::Literal('{' as u32)); + return Ok(()); + } + let low = self.source.get_while(usize::MAX, is_digit)?; + let high = if self.source.matches(",")? { + self.source.get_while(usize::MAX, is_digit)? + } else { + low.clone() + }; + if !self.source.matches("}")? { + out.push(Op::Literal('{' as u32)); + self.source.seek(here); + return Ok(()); + } + let min = parse_repeat_bound(&low)?.unwrap_or(0); + let max = parse_repeat_bound(&high)?.unwrap_or(MAXREPEAT); + if max < min { + return Err(self.error( + "min repeat greater than max repeat", + self.source.tell() - here, + )); + } + (min, max) + } + _ => unreachable!(), + }; + // Keep the names mutable to mirror the parser's bound handling while + // letting rustc prove both values are initialized. + min = min.min(MAXREPEAT); + max = max.min(MAXREPEAT); + let Some(previous) = out.pop() else { + return Err(self.error("nothing to repeat", self.source.tell() - here + token.len())); + }; + if matches!(previous, Op::At(_)) { + out.push(previous); + return Err(self.error("nothing to repeat", self.source.tell() - here + token.len())); + } + if matches!(previous, Op::Repeat { .. }) { + out.push(previous); + return Err(self.error("multiple repeat", self.source.tell() - here + token.len())); + } + let kind = if self.source.matches("?")? { + RepeatKind::Lazy + } else if self.source.matches("+")? { + RepeatKind::Possessive + } else { + RepeatKind::Greedy + }; + out.push(Op::Repeat { + kind, + min, + max, + body: vec![previous], + }); + Ok(()) + } + + fn parse_group( + &mut self, + prefix: &Pattern, + verbose: &mut bool, + nested: usize, + first: bool, + ) -> Result<Option<Op>, Error> { + let start = self.source.tell() - 1; + let mut capture = true; + let mut atomic = false; + let mut name = None; + let mut add_flags = 0; + let mut del_flags = 0; + if self.source.matches("?")? { + let Some(mut extension) = self.source.get()? else { + return Err(self.error("unexpected end of pattern", 0)); + }; + match extension.as_str() { + "P" => { + if self.source.matches("<")? { + let group_name = self.source.get_until(">", "group name")?; + self.check_group_name(&group_name, 1)?; + name = Some(group_name); + } else if self.source.matches("=")? { + let group_name = self.source.get_until(")", "group name")?; + self.check_group_name(&group_name, 1)?; + let Some(&group) = self.state.group_names.get(&group_name) else { + return Err(self.error( + format!("unknown group name '{group_name}'"), + group_name.chars().count() + 1, + )); + }; + if !self.state.group_is_closed(group) { + return Err( + self.error("cannot refer to an open group", group_name.len() + 1) + ); + } + self.state + .check_lookbehind_group(group, self.source.tell())?; + return Ok(Some(Op::GroupRef(group))); + } else { + let suffix = self.source.get()?.unwrap_or_default(); + return Err(self.error( + format!("unknown extension ?P{suffix}"), + suffix.chars().count() + 2, + )); + } + } + ":" => capture = false, + "#" => { + loop { + match self.source.get()? { + Some(value) if value == ")" => break, + Some(_) => {} + None => { + return Err(Error::at("missing ), unterminated comment", start)); + } + } + } + return Ok(None); + } + "=" | "!" | "<" => { + let mut behind = false; + if extension == "<" { + extension = self + .source + .get()? + .ok_or_else(|| self.error("unexpected end of pattern", 0))?; + if extension != "=" && extension != "!" { + return Err(self.error( + format!("unknown extension ?<{extension}"), + extension.chars().count() + 2, + )); + } + behind = true; + } + let previous_lookbehind = self.state.lookbehind_groups; + if behind && previous_lookbehind.is_none() { + self.state.lookbehind_groups = Some(self.state.group_widths.len()); + } + let body = self.parse_sub(*verbose, nested + 1)?; + if behind && previous_lookbehind.is_none() { + self.state.lookbehind_groups = None; + } + if !self.source.matches(")")? { + return Err(Error::at("missing ), unterminated subpattern", start)); + } + return Ok(Some(Op::Assert { + negative: extension == "!", + behind, + body, + })); + } + "(" => { + let condition = self.source.get_until(")", "group name")?; + let group = if condition.chars().all(|c| c.is_ascii_digit()) { + let group: usize = condition.parse().unwrap_or(usize::MAX); + if group == 0 { + return Err(self.error("bad group number", condition.len() + 1)); + } + if group >= MAXGROUPS { + return Err(self.error( + format!("invalid group reference {group}"), + condition.len() + 1, + )); + } + self.state + .forward_group_refs + .entry(group) + .or_insert(self.source.tell().saturating_sub(condition.len() + 1)); + group + } else { + self.check_group_name(&condition, 1)?; + *self.state.group_names.get(&condition).ok_or_else(|| { + self.error( + format!("unknown group name '{condition}'"), + condition.chars().count() + 1, + ) + })? + }; + self.state + .check_lookbehind_group(group, self.source.tell())?; + let yes = self.parse_sequence(*verbose, nested + 1, false)?; + let no = if self.source.matches("|")? { + let branch = self.parse_sequence(*verbose, nested + 1, false)?; + if self.source.token()?.as_deref() == Some("|") { + return Err( + self.error("conditional backref with more than two branches", 0) + ); + } + Some(branch) + } else { + None + }; + if !self.source.matches(")")? { + return Err(Error::at("missing ), unterminated subpattern", start)); + } + return Ok(Some(Op::GroupRefExists { group, yes, no })); + } + ">" => { + capture = false; + atomic = true; + } + value if flag_for(value).is_some() || value == "-" => { + let flags = self.parse_flags(value)?; + if let Some((add, del)) = flags { + add_flags = add; + del_flags = del; + capture = false; + } else { + if !first || !prefix.is_empty() { + return Err(Error::at( + "global flags not at the start of the expression", + start, + )); + } + *verbose = self.state.flags & FLAG_VERBOSE != 0; + return Ok(None); + } + } + _ => { + return Err(self.error( + format!("unknown extension ?{extension}"), + extension.chars().count() + 1, + )); + } + } + } + + let group = if capture { + let error_position = name + .as_ref() + .map(|value| self.source.tell().saturating_sub(value.chars().count() + 1)) + .unwrap_or(start); + Some( + self.state + .open_group(name) + .map_err(|error| Error::at(error.message, error_position))?, + ) + } else { + None + }; + let sub_verbose = + (*verbose || add_flags & FLAG_VERBOSE != 0) && del_flags & FLAG_VERBOSE == 0; + let body = self.parse_sub(sub_verbose, nested + 1)?; + if !self.source.matches(")")? { + return Err(Error::at("missing ), unterminated subpattern", start)); + } + if let Some(group) = group { + self.state.group_widths[group] = Some(width(&body, &self.state)); + } + Ok(Some(if atomic { + Op::Atomic(body) + } else { + Op::Subpattern { + group, + add_flags, + del_flags, + body, + } + })) + } + + fn parse_flags(&mut self, initial: &str) -> Result<Option<(u16, u16)>, Error> { + let mut token = initial.to_string(); + let mut add = 0; + let mut del = 0; + if token != "-" { + loop { + let flag = flag_for(&token).expect("caller checked flag"); + if token == "L" { + return Err(self.error( + "bad inline flags: cannot use 'L' flag with a str pattern", + 0, + )); + } + add |= flag; + if flag & TYPE_FLAGS != 0 && add & TYPE_FLAGS != flag { + return Err(self.error( + "bad inline flags: flags 'a', 'u' and 'L' are incompatible", + 0, + )); + } + token = self + .source + .get()? + .ok_or_else(|| self.error("missing -, : or )", 0))?; + if matches!(token.as_str(), ")" | "-" | ":") { + break; + } + if flag_for(&token).is_none() { + return Err(self.error( + if one_char(&token).is_some_and(char::is_alphabetic) { + "unknown flag" + } else { + "missing -, : or )" + }, + token.chars().count(), + )); + } + } + } + if token == ")" { + self.state.flags |= add; + return Ok(None); + } + if add & GLOBAL_FLAGS != 0 { + return Err(self.error("bad inline flags: cannot turn on global flag", 1)); + } + if token == "-" { + token = self + .source + .get()? + .ok_or_else(|| self.error("missing flag", 0))?; + loop { + let Some(flag) = flag_for(&token) else { + return Err(self.error( + if one_char(&token).is_some_and(char::is_alphabetic) { + "unknown flag" + } else { + "missing flag" + }, + token.chars().count(), + )); + }; + if flag & TYPE_FLAGS != 0 { + return Err(self.error( + "bad inline flags: cannot turn off flags 'a', 'u' and 'L'", + 0, + )); + } + del |= flag; + token = self + .source + .get()? + .ok_or_else(|| self.error("missing :", 0))?; + if token == ":" { + break; + } + } + } + if add & del != 0 { + return Err(self.error("bad inline flags: flag turned on and off", 1)); + } + Ok(Some((add, del))) + } + + fn check_group_name(&self, name: &str, offset: usize) -> Result<(), Error> { + let mut chars = name.chars(); + let valid = chars + .next() + .is_some_and(|ch| ch == '_' || unicode_ident::is_xid_start(ch)) + && chars.all(|ch| ch == '_' || unicode_ident::is_xid_continue(ch)); + if valid { + Ok(()) + } else { + Err(self.error( + format!("bad character in group name '{name}'"), + name.chars().count() + offset, + )) + } + } + + fn parse_escape(&mut self, token: &str, in_set: bool) -> Result<Op, Error> { + if let Some(value) = simple_escape(token, in_set) { + return Ok(value); + } + let code = token.chars().nth(1).expect("escape token"); + match code { + 'x' | 'u' | 'U' => { + let count = match code { + 'x' => 2, + 'u' => 4, + _ => 8, + }; + let digits = self.source.get_while(count, is_hex)?; + let whole = format!("{token}{digits}"); + if digits.len() != count { + return Err(self.error(format!("incomplete escape {whole}"), whole.len())); + } + let value = u32::from_str_radix(&digits, 16).expect("hex checked"); + if char::from_u32(value).is_none() { + return Err(self.error(format!("bad escape {whole}"), whole.len())); + } + Ok(Op::Literal(value)) + } + 'N' => { + if !self.source.matches("{")? { + return Err(self.error("missing {", 0)); + } + let name = self.source.get_until("}", "character name")?; + let value = unicode_names2::character(&name).ok_or_else(|| { + self.error( + format!("undefined character name '{name}'"), + name.chars().count() + 4, + ) + })?; + Ok(Op::Literal(value as u32)) + } + '0' if !in_set => { + let extra = self.source.get_while(2, is_octal)?; + Ok(Op::Literal( + u32::from_str_radix(&format!("0{extra}"), 8).unwrap(), + )) + } + c if c.is_ascii_digit() => self.parse_numeric_escape(token, in_set), + c if c.is_ascii_alphabetic() => { + Err(self.error(format!("bad escape {token}"), token.chars().count())) + } + c => Ok(Op::Literal(c as u32)), + } + } + + fn parse_numeric_escape(&mut self, token: &str, in_set: bool) -> Result<Op, Error> { + let mut digits = token[1..].to_string(); + if in_set { + digits.push_str(&self.source.get_while(2, is_octal)?); + let value = u32::from_str_radix(&digits, 8) + .map_err(|_| self.error(format!("bad escape \\{digits}"), digits.len() + 1))?; + if value > 0o377 { + return Err(self.error( + format!("octal escape value \\{digits} outside of range 0-0o377"), + digits.len() + 1, + )); + } + return Ok(Op::Literal(value)); + } + + if self + .source + .token()? + .as_deref() + .is_some_and(|s| s.chars().all(|c| c.is_ascii_digit())) + { + digits.push_str(&self.source.get()?.expect("peeked token")); + if digits.chars().take(2).all(is_octal) + && self + .source + .token()? + .as_deref() + .is_some_and(|s| s.chars().all(is_octal)) + { + digits.push_str(&self.source.get()?.expect("peeked token")); + let value = u32::from_str_radix(&digits, 8).unwrap(); + if value > 0o377 { + return Err(self.error( + format!("octal escape value \\{digits} outside of range 0-0o377"), + digits.len() + 1, + )); + } + return Ok(Op::Literal(value)); + } + } + let group: usize = digits.parse().unwrap_or(usize::MAX); + if group < self.state.group_widths.len() { + if !self.state.group_is_closed(group) { + return Err(self.error("cannot refer to an open group", digits.len() + 1)); + } + self.state + .check_lookbehind_group(group, self.source.tell())?; + Ok(Op::GroupRef(group)) + } else { + Err(self.error(format!("invalid group reference {group}"), digits.len())) + } + } +} + +fn one_char(token: &str) -> Option<char> { + let mut chars = token.chars(); + let ch = chars.next()?; + chars.next().is_none().then_some(ch) +} + +fn is_digit(ch: char) -> bool { + ch.is_ascii_digit() +} +fn is_octal(ch: char) -> bool { + matches!(ch, '0'..='7') +} +fn is_hex(ch: char) -> bool { + ch.is_ascii_hexdigit() +} +fn is_verbose_whitespace(ch: char) -> bool { + matches!(ch, ' ' | '\t' | '\n' | '\r' | '\u{b}' | '\u{c}') +} + +fn parse_repeat_bound(value: &str) -> Result<Option<usize>, Error> { + if value.is_empty() { + return Ok(None); + } + let value = value + .parse::<u64>() + .map_err(|_| Error::plain("the repetition number is too large"))?; + if value >= MAXREPEAT as u64 { + Err(Error::plain("the repetition number is too large")) + } else { + Ok(Some(value as usize)) + } +} + +fn simple_escape(token: &str, in_set: bool) -> Option<Op> { + let literal = match token { + "\\a" => Some('\u{7}'), + "\\b" if in_set => Some('\u{8}'), + "\\f" => Some('\u{c}'), + "\\n" => Some('\n'), + "\\r" => Some('\r'), + "\\t" => Some('\t'), + "\\v" => Some('\u{b}'), + "\\\\" => Some('\\'), + _ => None, + }; + if let Some(value) = literal { + return Some(Op::Literal(value as u32)); + } + let category = match token { + "\\d" => Some(SreCatCode::DIGIT as u32), + "\\D" => Some(SreCatCode::NOT_DIGIT as u32), + "\\s" => Some(SreCatCode::SPACE as u32), + "\\S" => Some(SreCatCode::NOT_SPACE as u32), + "\\w" => Some(SreCatCode::WORD as u32), + "\\W" => Some(SreCatCode::NOT_WORD as u32), + _ => None, + }; + if let Some(category) = category { + return Some(Op::Set(vec![SetOp::Category(category)])); + } + if in_set { + return None; + } + Some(match token { + "\\A" => Op::At(SreAtCode::BEGINNING_STRING as u32), + "\\b" => Op::At(SreAtCode::BOUNDARY as u32), + "\\B" => Op::At(SreAtCode::NON_BOUNDARY as u32), + "\\Z" => Op::At(SreAtCode::END_STRING as u32), + _ => return None, + }) +} + +fn flag_for(token: &str) -> Option<u16> { + Some(match token { + "i" => FLAG_IGNORECASE, + "L" => FLAG_LOCALE, + "m" => FLAG_MULTILINE, + "s" => FLAG_DOTALL, + "x" => FLAG_VERBOSE, + "a" => FLAG_ASCII, + "t" => FLAG_TEMPLATE, + "u" => FLAG_UNICODE, + _ => return None, + }) +} + +fn fix_flags(mut flags: u16) -> Result<u16, Error> { + if flags & FLAG_LOCALE != 0 { + return Err(Error::plain("cannot use LOCALE flag with a str pattern")); + } + if flags & FLAG_ASCII == 0 { + flags |= FLAG_UNICODE; + } else if flags & FLAG_UNICODE != 0 { + return Err(Error::plain("ASCII and UNICODE flags are incompatible")); + } + Ok(flags) +} + +fn dedup_set(items: &mut Vec<SetOp>) { + let mut unique = Vec::with_capacity(items.len()); + for item in items.drain(..) { + if !unique.contains(&item) { + unique.push(item); + } + } + *items = unique; +} + +fn width(pattern: &Pattern, state: &ParseState) -> (u64, u64) { + let (mut low, mut high) = (0u64, 0u64); + for op in pattern { + let (item_low, item_high) = match op { + Op::Branch(branches) => branches.iter().map(|branch| width(branch, state)).fold( + (MAX_WIDTH, 0), + |(low, high), (branch_low, branch_high)| { + (low.min(branch_low), high.max(branch_high)) + }, + ), + Op::Atomic(body) | Op::Subpattern { body, .. } => width(body, state), + Op::Repeat { min, max, body, .. } => { + let (body_low, body_high) = width(body, state); + let low = body_low.saturating_mul(*min as u64); + let high = if *max == MAXREPEAT && body_high != 0 { + MAX_WIDTH + } else { + body_high.saturating_mul(*max as u64) + }; + (low, high) + } + Op::Literal(_) | Op::NotLiteral(_) | Op::Set(_) | Op::Any => (1, 1), + Op::GroupRef(group) => state.group_widths[*group].unwrap_or((0, MAX_WIDTH)), + Op::GroupRefExists { yes, no, .. } => { + let (yes_low, yes_high) = width(yes, state); + let (no_low, no_high) = no.as_ref().map_or((0, 0), |body| width(body, state)); + (yes_low.min(no_low), yes_high.max(no_high)) + } + Op::Assert { .. } | Op::At(_) => (0, 0), + }; + low = low.saturating_add(item_low); + high = high.saturating_add(item_high); + } + (low, high) +} + +fn emit(codes: &mut Vec<u32>, opcode: SreOpcode) { + codes.push(opcode as u32); +} + +fn combine_flags(mut flags: u16, add: u16, del: u16) -> u16 { + if add & TYPE_FLAGS != 0 { + flags &= !TYPE_FLAGS; + } + (flags | add) & !del +} + +// Mirrors CPython 3.12's `_get_charset_prefix`: scoped type flags affect the +// real opcode but the INFO search charset is compiled with the outer flags. +// That observable quirk is why `(?a:\W)` search differs from fullmatch. +fn first_charset(pattern: &Pattern) -> Option<&[SetOp]> { + let first = pattern.first()?; + match first { + Op::Set(items) => Some(items), + Op::Subpattern { body, .. } => first_charset(body), + _ => None, + } +} + +fn compile_pattern( + pattern: &Pattern, + flags: u16, + state: &ParseState, + codes: &mut Vec<u32>, +) -> Result<(), Error> { + for op in pattern { + match op { + Op::Literal(value) => compile_literal(*value, false, flags, codes), + Op::NotLiteral(value) => compile_literal(*value, true, flags, codes), + Op::Set(items) => compile_set(items, flags, codes), + Op::Any => emit( + codes, + if flags & FLAG_DOTALL != 0 { + SreOpcode::ANY_ALL + } else { + SreOpcode::ANY + }, + ), + Op::Repeat { + kind, + min, + max, + body, + } => { + let simple = is_simple(body); + let opcode = match (kind, simple) { + (RepeatKind::Greedy, true) => SreOpcode::REPEAT_ONE, + (RepeatKind::Lazy, true) => SreOpcode::MIN_REPEAT_ONE, + (RepeatKind::Possessive, true) => SreOpcode::POSSESSIVE_REPEAT_ONE, + (RepeatKind::Greedy | RepeatKind::Lazy, false) => SreOpcode::REPEAT, + (RepeatKind::Possessive, false) => SreOpcode::POSSESSIVE_REPEAT, + }; + emit(codes, opcode); + let skip = codes.len(); + codes.extend([0, *min as u32, *max as u32]); + compile_pattern(body, flags, state, codes)?; + match (kind, simple) { + (RepeatKind::Greedy, false) => { + codes[skip] = (codes.len() - skip) as u32; + emit(codes, SreOpcode::MAX_UNTIL); + } + (RepeatKind::Lazy, false) => { + codes[skip] = (codes.len() - skip) as u32; + emit(codes, SreOpcode::MIN_UNTIL); + } + _ => { + emit(codes, SreOpcode::SUCCESS); + codes[skip] = (codes.len() - skip) as u32; + } + } + } + Op::Subpattern { + group, + add_flags, + del_flags, + body, + } => { + if let Some(group) = group { + emit(codes, SreOpcode::MARK); + codes.push(((group - 1) * 2) as u32); + } + compile_pattern( + body, + combine_flags(flags, *add_flags, *del_flags), + state, + codes, + )?; + if let Some(group) = group { + emit(codes, SreOpcode::MARK); + codes.push(((group - 1) * 2 + 1) as u32); + } + } + Op::Atomic(body) => { + emit(codes, SreOpcode::ATOMIC_GROUP); + let skip = codes.len(); + codes.push(0); + compile_pattern(body, flags, state, codes)?; + emit(codes, SreOpcode::SUCCESS); + codes[skip] = (codes.len() - skip) as u32; + } + Op::Assert { + negative, + behind, + body, + } => { + emit( + codes, + if *negative { + SreOpcode::ASSERT_NOT + } else { + SreOpcode::ASSERT + }, + ); + let skip = codes.len(); + codes.push(0); + if *behind { + let (low, high) = width(body, state); + if low != high { + return Err(Error::plain("look-behind requires fixed-width pattern")); + } + codes.push(low as u32); + } else { + codes.push(0); + } + compile_pattern(body, flags, state, codes)?; + emit(codes, SreOpcode::SUCCESS); + codes[skip] = (codes.len() - skip) as u32; + } + Op::At(value) => { + emit(codes, SreOpcode::AT); + codes.push(map_at(*value, flags)); + } + Op::Branch(branches) => { + emit(codes, SreOpcode::BRANCH); + let mut tails = Vec::new(); + for branch in branches { + let skip = codes.len(); + codes.push(0); + compile_pattern(branch, flags, state, codes)?; + emit(codes, SreOpcode::JUMP); + tails.push(codes.len()); + codes.push(0); + codes[skip] = (codes.len() - skip) as u32; + } + emit(codes, SreOpcode::FAILURE); + let end = codes.len(); + for tail in tails { + codes[tail] = (end - tail) as u32; + } + } + Op::GroupRef(group) => { + emit( + codes, + match ignore_mode(flags) { + IgnoreMode::None => SreOpcode::GROUPREF, + IgnoreMode::Ascii => SreOpcode::GROUPREF_IGNORE, + IgnoreMode::Unicode => SreOpcode::GROUPREF_UNI_IGNORE, + }, + ); + codes.push((group - 1) as u32); + } + Op::GroupRefExists { group, yes, no } => { + emit(codes, SreOpcode::GROUPREF_EXISTS); + codes.push((group - 1) as u32); + let skip_yes = codes.len(); + codes.push(0); + compile_pattern(yes, flags, state, codes)?; + if let Some(no) = no { + emit(codes, SreOpcode::JUMP); + let skip_no = codes.len(); + codes.push(0); + codes[skip_yes] = (codes.len() - skip_yes + 1) as u32; + compile_pattern(no, flags, state, codes)?; + codes[skip_no] = (codes.len() - skip_no) as u32; + } else { + codes[skip_yes] = (codes.len() - skip_yes + 1) as u32; + } + } + } + } + Ok(()) +} + +fn is_simple(pattern: &Pattern) -> bool { + if pattern.len() != 1 { + return false; + } + match &pattern[0] { + Op::Literal(_) | Op::NotLiteral(_) | Op::Set(_) | Op::Any => true, + Op::Subpattern { + group: None, body, .. + } => is_simple(body), + _ => false, + } +} + +#[derive(Clone, Copy)] +enum IgnoreMode { + None, + Ascii, + Unicode, +} + +fn ignore_mode(flags: u16) -> IgnoreMode { + if flags & FLAG_IGNORECASE == 0 { + IgnoreMode::None + } else if flags & FLAG_ASCII != 0 { + IgnoreMode::Ascii + } else { + IgnoreMode::Unicode + } +} + +fn compile_literal(value: u32, negate: bool, flags: u16, codes: &mut Vec<u32>) { + match ignore_mode(flags) { + IgnoreMode::None => { + emit( + codes, + if negate { + SreOpcode::NOT_LITERAL + } else { + SreOpcode::LITERAL + }, + ); + codes.push(value); + } + IgnoreMode::Ascii => { + emit( + codes, + if negate { + SreOpcode::NOT_LITERAL_IGNORE + } else { + SreOpcode::LITERAL_IGNORE + }, + ); + codes.push(crate::string::lower_ascii(value)); + } + IgnoreMode::Unicode => { + let lower = crate::string::lower_unicode(value); + let extras = extra_cases(lower); + if extras.is_empty() { + emit( + codes, + if negate { + SreOpcode::NOT_LITERAL_UNI_IGNORE + } else { + SreOpcode::LITERAL_UNI_IGNORE + }, + ); + codes.push(lower); + } else { + emit(codes, SreOpcode::IN_UNI_IGNORE); + let skip = codes.len(); + codes.push(0); + if negate { + emit(codes, SreOpcode::NEGATE); + } + for value in core::iter::once(lower).chain(extras.iter().copied()) { + emit(codes, SreOpcode::LITERAL); + codes.push(value); + } + emit(codes, SreOpcode::FAILURE); + codes[skip] = (codes.len() - skip) as u32; + } + } + } +} + +fn compile_set(items: &[SetOp], flags: u16, codes: &mut Vec<u32>) { + emit( + codes, + match ignore_mode(flags) { + IgnoreMode::None => SreOpcode::IN, + IgnoreMode::Ascii => SreOpcode::IN_IGNORE, + IgnoreMode::Unicode => SreOpcode::IN_UNI_IGNORE, + }, + ); + let skip = codes.len(); + codes.push(0); + for item in items { + match item { + SetOp::Negate => emit(codes, SreOpcode::NEGATE), + SetOp::Literal(value) => { + let lower = match ignore_mode(flags) { + IgnoreMode::None => *value, + IgnoreMode::Ascii => crate::string::lower_ascii(*value), + IgnoreMode::Unicode => crate::string::lower_unicode(*value), + }; + emit(codes, SreOpcode::LITERAL); + codes.push(lower); + if matches!(ignore_mode(flags), IgnoreMode::Unicode) { + for extra in extra_cases(lower) { + emit(codes, SreOpcode::LITERAL); + codes.push(*extra); + } + } + } + SetOp::Range(low, high) => { + emit( + codes, + if matches!(ignore_mode(flags), IgnoreMode::Unicode) { + SreOpcode::RANGE_UNI_IGNORE + } else { + SreOpcode::RANGE + }, + ); + codes.extend([*low, *high]); + } + SetOp::Category(value) => { + emit(codes, SreOpcode::CATEGORY); + codes.push(map_category(*value, flags)); + } + } + } + emit(codes, SreOpcode::FAILURE); + codes[skip] = (codes.len() - skip) as u32; +} + +fn map_at(value: u32, flags: u16) -> u32 { + if flags & FLAG_MULTILINE != 0 { + if value == SreAtCode::BEGINNING as u32 { + return SreAtCode::BEGINNING_LINE as u32; + } + if value == SreAtCode::END as u32 { + return SreAtCode::END_LINE as u32; + } + } + if flags & FLAG_UNICODE != 0 { + if value == SreAtCode::BOUNDARY as u32 { + return SreAtCode::UNI_BOUNDARY as u32; + } + if value == SreAtCode::NON_BOUNDARY as u32 { + return SreAtCode::UNI_NON_BOUNDARY as u32; + } + } + value +} + +fn map_category(value: u32, flags: u16) -> u32 { + if flags & FLAG_UNICODE == 0 { + return value; + } + match value { + x if x == SreCatCode::DIGIT as u32 => SreCatCode::UNI_DIGIT as u32, + x if x == SreCatCode::NOT_DIGIT as u32 => SreCatCode::UNI_NOT_DIGIT as u32, + x if x == SreCatCode::SPACE as u32 => SreCatCode::UNI_SPACE as u32, + x if x == SreCatCode::NOT_SPACE as u32 => SreCatCode::UNI_NOT_SPACE as u32, + x if x == SreCatCode::WORD as u32 => SreCatCode::UNI_WORD as u32, + x if x == SreCatCode::NOT_WORD as u32 => SreCatCode::UNI_NOT_WORD as u32, + x if x == SreCatCode::LINEBREAK as u32 => SreCatCode::UNI_LINEBREAK as u32, + x if x == SreCatCode::NOT_LINEBREAK as u32 => SreCatCode::UNI_NOT_LINEBREAK as u32, + _ => value, + } +} + +fn extra_cases(value: u32) -> &'static [u32] { + match value { + 0x0069 => &[0x0131], + 0x0073 => &[0x017f], + 0x00b5 => &[0x03bc], + 0x0131 => &[0x0069], + 0x017f => &[0x0073], + 0x0345 => &[0x03b9, 0x1fbe], + 0x0390 => &[0x1fd3], + 0x03b0 => &[0x1fe3], + 0x03b2 => &[0x03d0], + 0x03b5 => &[0x03f5], + 0x03b8 => &[0x03d1], + 0x03b9 => &[0x0345, 0x1fbe], + 0x03ba => &[0x03f0], + 0x03bc => &[0x00b5], + 0x03c0 => &[0x03d6], + 0x03c1 => &[0x03f1], + 0x03c2 => &[0x03c3], + 0x03c3 => &[0x03c2], + 0x03c6 => &[0x03d5], + 0x03d0 => &[0x03b2], + 0x03d1 => &[0x03b8], + 0x03d5 => &[0x03c6], + 0x03d6 => &[0x03c0], + 0x03f0 => &[0x03ba], + 0x03f1 => &[0x03c1], + 0x03f5 => &[0x03b5], + 0x0432 => &[0x1c80], + 0x0434 => &[0x1c81], + 0x043e => &[0x1c82], + 0x0441 => &[0x1c83], + 0x0442 => &[0x1c84, 0x1c85], + 0x044a => &[0x1c86], + 0x0463 => &[0x1c87], + 0x1c80 => &[0x0432], + 0x1c81 => &[0x0434], + 0x1c82 => &[0x043e], + 0x1c83 => &[0x0441], + 0x1c84 => &[0x0442, 0x1c85], + 0x1c85 => &[0x0442, 0x1c84], + 0x1c86 => &[0x044a], + 0x1c87 => &[0x0463], + 0x1c88 => &[0xa64b], + 0x1e61 => &[0x1e9b], + 0x1e9b => &[0x1e61], + 0x1fbe => &[0x0345, 0x03b9], + 0x1fd3 => &[0x0390], + 0x1fe3 => &[0x03b0], + 0xa64b => &[0x1c88], + 0xfb05 => &[0xfb06], + 0xfb06 => &[0xfb05], + _ => &[], + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Request, SearchIter, State, StrDrive}; + + fn findall(pattern: &str, text: &str, ignore_case: bool) -> Vec<Vec<String>> { + let compiled = compile(pattern, ignore_case).unwrap(); + let req = Request::new(text, 0, text.count(), &compiled.codes, false); + let mut iter = SearchIter { + req, + state: State::default(), + }; + let chars: Vec<char> = text.chars().collect(); + let mut matches = Vec::new(); + while iter.next().is_some() { + let mut groups = Vec::new(); + if compiled.groups == 0 { + groups.push( + chars[iter.state.start..iter.state.cursor.position] + .iter() + .collect(), + ); + } else { + for group in 0..compiled.groups { + let (start, end) = iter.state.marks.get(group); + groups.push(match (start.into_option(), end.into_option()) { + (Some(start), Some(end)) => chars[start..end].iter().collect(), + _ => String::new(), + }); + } + } + matches.push(groups); + } + matches + } + + #[test] + fn compiles_cpython_312_constructs() { + for pattern in [ + r"\d+", + r"(a)|(b)", + r"apple(?= pie)", + r"(?P<x>a)(?P=x)", + r"(?<=abc)def", + r"(?>x)++x", + r"(a)?(?(1)b|c)", + r"\N{EM DASH}", + ] { + let compiled = compile(pattern, pattern.contains("apple")).unwrap(); + assert_eq!(compiled.codes[0], SreOpcode::INFO as u32); + assert_eq!(*compiled.codes.last().unwrap(), SreOpcode::SUCCESS as u32); + } + } + + #[test] + fn maps_cpython_312_errors() { + for (pattern, error) in [ + ("(", "missing ), unterminated subpattern at position 0"), + ("[", "unterminated character set at position 0"), + ("*", "nothing to repeat at position 0"), + (r"\x", r"incomplete escape \x at position 0"), + ( + "a(?i)b", + "global flags not at the start of the expression at position 1", + ), + ("(?<=a*)b", "look-behind requires fixed-width pattern"), + (r"\1", "invalid group reference 1 at position 1"), + ("[z-a]", "bad character range z-a at position 1"), + ("a{4,2}", "min repeat greater than max repeat at position 2"), + ] { + assert_eq!( + compile(pattern, false).unwrap_err().to_string(), + error, + "{pattern}" + ); + } + } + + #[test] + fn executes_cpython_312_findall_semantics() { + assert_eq!( + findall(r"\d+", "price 42 then 99", false), + vec![vec![String::from("42")], vec![String::from("99")]] + ); + assert_eq!( + findall(r"(a)|(b)", "ab", false), + vec![ + vec![String::from("a"), String::new()], + vec![String::new(), String::from("b")] + ] + ); + assert_eq!( + findall(r"apple(?= pie)", "APPLE PIE apple tart", true), + vec![vec![String::from("APPLE")]] + ); + assert_eq!( + findall(r"(?P<x>a)(?P=x)", "zaa", false), + vec![vec![String::from("a")]] + ); + assert_eq!( + findall(r"price (\d+)", "price 42 then price 99", false), + vec![vec![String::from("42")], vec![String::from("99")]] + ); + assert_eq!( + findall(r"(\w+)=(\d+)", "a=1 b=2", false), + vec![ + vec![String::from("a"), String::from("1")], + vec![String::from("b"), String::from("2")] + ] + ); + assert_eq!( + findall(r"(?<=abc)def", "abcdef", false), + vec![vec![String::from("def")]] + ); + assert_eq!( + findall(r"\N{EM DASH}", "x—y", false), + vec![vec![String::from("—")]] + ); + } + + #[test] + fn minimized_cpython_312_differential_regressions() { + assert_eq!(findall(r"\d", "٣", false), vec![vec![String::from("٣")]]); + assert_eq!( + findall("[a-z]", "İıſK", true), + vec![ + vec![String::from("İ")], + vec![String::from("ı")], + vec![String::from("ſ")], + vec![String::from("K")], + ] + ); + + // CPython 3.12 compiles a scoped ASCII set correctly for matching, + // but its INFO search charset uses the outer Unicode flags. + assert!(findall(r"(?a:\W)", "Ω", false).is_empty()); + + assert_eq!( + findall(r"(?:a?)*", "a", false), + vec![vec![String::from("a")], vec![String::new()]] + ); + assert_eq!( + findall(r"(?=(a+))", "aa", false), + vec![vec![String::from("aa")], vec![String::from("a")]] + ); + assert!(findall(r"a*+a", "aaa", false).is_empty()); + + assert_eq!( + compile("(?P<x>a)(?P<x>b)", false).unwrap_err().to_string(), + "redefinition of group name 'x' as group 2; was group 1 at position 12" + ); + } +} diff --git a/browser/vendor/rustpython-sre_engine/src/constants.rs b/browser/vendor/rustpython-sre_engine/src/constants.rs new file mode 100644 index 000000000..b38ecb109 --- /dev/null +++ b/browser/vendor/rustpython-sre_engine/src/constants.rs @@ -0,0 +1,130 @@ +/* + * Secret Labs' Regular Expression Engine + * + * regular expression matching engine + * + * Auto-generated by scripts/generate_sre_constants.py from + * Lib/re/_constants.py. + * + * Copyright (c) 1997-2001 by Secret Labs AB. All rights reserved. + * + * See the sre.c file for information on usage and redistribution. + */ + +use bitflags::bitflags; + +pub const SRE_MAGIC: usize = 20230612; + +#[allow(non_camel_case_types, clippy::upper_case_acronyms)] +#[derive(num_enum::TryFromPrimitive, Copy, Clone, Debug, PartialEq, Eq)] +#[repr(u32)] +pub enum SreOpcode { + FAILURE = 0, + SUCCESS = 1, + ANY = 2, + ANY_ALL = 3, + ASSERT = 4, + ASSERT_NOT = 5, + AT = 6, + BRANCH = 7, + CATEGORY = 8, + CHARSET = 9, + BIGCHARSET = 10, + GROUPREF = 11, + GROUPREF_EXISTS = 12, + IN = 13, + INFO = 14, + JUMP = 15, + LITERAL = 16, + MARK = 17, + MAX_UNTIL = 18, + MIN_UNTIL = 19, + NOT_LITERAL = 20, + NEGATE = 21, + RANGE = 22, + REPEAT = 23, + REPEAT_ONE = 24, + SUBPATTERN = 25, + MIN_REPEAT_ONE = 26, + ATOMIC_GROUP = 27, + POSSESSIVE_REPEAT = 28, + POSSESSIVE_REPEAT_ONE = 29, + GROUPREF_IGNORE = 30, + IN_IGNORE = 31, + LITERAL_IGNORE = 32, + NOT_LITERAL_IGNORE = 33, + GROUPREF_LOC_IGNORE = 34, + IN_LOC_IGNORE = 35, + LITERAL_LOC_IGNORE = 36, + NOT_LITERAL_LOC_IGNORE = 37, + GROUPREF_UNI_IGNORE = 38, + IN_UNI_IGNORE = 39, + LITERAL_UNI_IGNORE = 40, + NOT_LITERAL_UNI_IGNORE = 41, + RANGE_UNI_IGNORE = 42, +} + +#[allow(non_camel_case_types, clippy::upper_case_acronyms)] +#[derive(num_enum::TryFromPrimitive, Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u32)] +pub enum SreAtCode { + BEGINNING = 0, + BEGINNING_LINE = 1, + BEGINNING_STRING = 2, + BOUNDARY = 3, + NON_BOUNDARY = 4, + END = 5, + END_LINE = 6, + END_STRING = 7, + LOC_BOUNDARY = 8, + LOC_NON_BOUNDARY = 9, + UNI_BOUNDARY = 10, + UNI_NON_BOUNDARY = 11, +} + +#[allow(non_camel_case_types, clippy::upper_case_acronyms)] +#[derive(num_enum::TryFromPrimitive, Clone, Copy, Debug)] +#[repr(u32)] +pub enum SreCatCode { + DIGIT = 0, + NOT_DIGIT = 1, + SPACE = 2, + NOT_SPACE = 3, + WORD = 4, + NOT_WORD = 5, + LINEBREAK = 6, + NOT_LINEBREAK = 7, + LOC_WORD = 8, + LOC_NOT_WORD = 9, + UNI_DIGIT = 10, + UNI_NOT_DIGIT = 11, + UNI_SPACE = 12, + UNI_NOT_SPACE = 13, + UNI_WORD = 14, + UNI_NOT_WORD = 15, + UNI_LINEBREAK = 16, + UNI_NOT_LINEBREAK = 17, +} + +bitflags! { +#[derive(Debug, PartialEq, Eq, Clone, Copy)] + pub struct SreFlag: u16 { + const IGNORECASE = 2; + const LOCALE = 4; + const MULTILINE = 8; + const DOTALL = 16; + const UNICODE = 32; + const VERBOSE = 64; + const DEBUG = 128; + const ASCII = 256; + } +} + +bitflags! { + #[derive(Clone, Copy)] + pub struct SreInfo: u32 { + const PREFIX = 1; + const LITERAL = 2; + const CHARSET = 4; + } +} diff --git a/browser/vendor/rustpython-sre_engine/src/engine.rs b/browser/vendor/rustpython-sre_engine/src/engine.rs new file mode 100644 index 000000000..759744c77 --- /dev/null +++ b/browser/vendor/rustpython-sre_engine/src/engine.rs @@ -0,0 +1,1423 @@ +// good luck to those that follow; here be dragons + +use crate::string::{ + is_digit, is_linebreak, is_loc_word, is_space, is_uni_digit, is_uni_linebreak, is_uni_space, + is_uni_word, is_word, lower_ascii, lower_locate, lower_unicode, upper_locate, upper_unicode, +}; + +use super::{MAXREPEAT, SreAtCode, SreCatCode, SreInfo, SreOpcode, StrDrive, StringCursor}; +use alloc::{vec, vec::Vec}; +use core::{convert::TryFrom, ptr::null}; +use optional::Optioned; + +#[derive(Debug, Clone, Copy)] +pub struct Request<'a, S> { + pub string: S, + pub start: usize, + pub end: usize, + pub pattern_codes: &'a [u32], + pub match_all: bool, + pub must_advance: bool, +} + +impl<'a, S: StrDrive> Request<'a, S> { + pub fn new( + string: S, + start: usize, + end: usize, + pattern_codes: &'a [u32], + match_all: bool, + ) -> Self { + let end = core::cmp::min(end, string.count()); + let start = core::cmp::min(start, end); + + Self { + string, + start, + end, + pattern_codes, + match_all, + must_advance: false, + } + } +} + +#[derive(Debug)] +pub struct Marks { + last_index: isize, + marks: Vec<Optioned<usize>>, + marks_stack: Vec<(Vec<Optioned<usize>>, isize)>, +} + +impl Default for Marks { + fn default() -> Self { + Self { + last_index: -1, + marks: Vec::new(), + marks_stack: Vec::new(), + } + } +} + +impl Marks { + pub fn get(&self, group_index: usize) -> (Optioned<usize>, Optioned<usize>) { + let marks_index = 2 * group_index; + if marks_index + 1 < self.marks.len() { + (self.marks[marks_index], self.marks[marks_index + 1]) + } else { + (Optioned::none(), Optioned::none()) + } + } + + pub const fn last_index(&self) -> isize { + self.last_index + } + + pub fn raw(&self) -> &[Optioned<usize>] { + self.marks.as_slice() + } + + fn set(&mut self, mark_nr: usize, position: usize) { + if mark_nr & 1 != 0 { + self.last_index = mark_nr as isize / 2 + 1; + } + if mark_nr >= self.marks.len() { + self.marks.resize(mark_nr + 1, Optioned::none()); + } + self.marks[mark_nr] = Optioned::some(position); + } + + fn push(&mut self) { + self.marks_stack.push((self.marks.clone(), self.last_index)); + } + + fn pop(&mut self) { + let (marks, last_index) = self.marks_stack.pop().unwrap(); + self.marks = marks; + self.last_index = last_index; + } + + fn pop_keep(&mut self) { + let (marks, last_index) = self.marks_stack.last().unwrap().clone(); + self.marks = marks; + self.last_index = last_index; + } + + fn pop_discard(&mut self) { + self.marks_stack.pop(); + } + + fn clear(&mut self) { + self.last_index = -1; + self.marks.clear(); + self.marks_stack.clear(); + } +} + +#[derive(Debug, Default)] +pub struct State { + pub start: usize, + pub marks: Marks, + pub cursor: StringCursor, + repeat_stack: Vec<RepeatContext>, +} + +impl State { + pub fn reset<S: StrDrive>(&mut self, req: &Request<'_, S>, start: usize) { + self.marks.clear(); + self.repeat_stack.clear(); + self.start = start; + req.string.adjust_cursor(&mut self.cursor, start); + } + + pub fn py_match<S: StrDrive>(&mut self, req: &Request<'_, S>) -> bool { + self.start = req.start; + req.string.adjust_cursor(&mut self.cursor, self.start); + + let ctx = MatchContext { + cursor: self.cursor, + code_position: 0, + toplevel: true, + jump: Jump::OpCode, + repeat_ctx_id: usize::MAX, + count: -1, + }; + _match(req, self, ctx) + } + + pub fn search<S: StrDrive>(&mut self, mut req: Request<'_, S>) -> bool { + self.start = req.start; + req.string.adjust_cursor(&mut self.cursor, self.start); + + if req.start > req.end { + return false; + } + + let mut end = req.end; + + let mut ctx = MatchContext { + cursor: self.cursor, + code_position: 0, + toplevel: true, + jump: Jump::OpCode, + repeat_ctx_id: usize::MAX, + count: -1, + }; + + if ctx.peek_code(&req, 0) == SreOpcode::INFO as u32 { + /* optimization info block */ + /* <INFO> <1=skip> <2=flags> <3=min> <4=max> <5=prefix info> */ + let min = ctx.peek_code(&req, 3) as usize; + + if ctx.remaining_chars(&req) < min { + return false; + } + + if min > 1 { + /* adjust end point (but make sure we leave at least one + character in there, so literal search will work) */ + // no overflow can happen as remaining chars >= min + end -= min - 1; + + // adjust ctx position + if end < ctx.cursor.position { + let skip = end - self.cursor.position; + S::skip(&mut self.cursor, skip); + } + } + + let flags = SreInfo::from_bits_truncate(ctx.peek_code(&req, 2)); + + if flags.contains(SreInfo::PREFIX) { + if flags.contains(SreInfo::LITERAL) { + return search_info_literal::<true, S>(&mut req, self, ctx); + } else { + return search_info_literal::<false, S>(&mut req, self, ctx); + } + } else if flags.contains(SreInfo::CHARSET) { + return search_info_charset(&mut req, self, ctx); + } + // fallback to general search + // skip OP INFO + ctx.skip_code_from(&req, 1); + } + + if _match(&req, self, ctx) { + return true; + } + + if ctx.try_peek_code_as::<SreOpcode, _>(&req, 0).unwrap() == SreOpcode::AT + && (ctx.try_peek_code_as::<SreAtCode, _>(&req, 1).unwrap() == SreAtCode::BEGINNING + || ctx.try_peek_code_as::<SreAtCode, _>(&req, 1).unwrap() + == SreAtCode::BEGINNING_STRING) + { + self.cursor.position = req.end; + self.cursor.ptr = null(); + // self.reset(&req, req.end); + return false; + } + + req.must_advance = false; + ctx.toplevel = false; + while req.start < end { + req.start += 1; + self.reset(&req, req.start); + ctx.cursor = self.cursor; + + if _match(&req, self, ctx) { + return true; + } + } + false + } +} + +pub struct SearchIter<'a, S: StrDrive> { + pub req: Request<'a, S>, + pub state: State, +} + +impl<S: StrDrive> Iterator for SearchIter<'_, S> { + type Item = (); + + fn next(&mut self) -> Option<Self::Item> { + if self.req.start > self.req.end { + return None; + } + + self.state.reset(&self.req, self.req.start); + if !self.state.search(self.req) { + return None; + } + + self.req.must_advance = self.state.cursor.position == self.state.start; + self.req.start = self.state.cursor.position; + + Some(()) + } +} + +#[derive(Debug, Clone, Copy)] +enum Jump { + OpCode, + Assert1, + AssertNot1, + Branch1, + Branch2, + Repeat1, + UntilBacktrace, + MaxUntil2, + MaxUntil3, + MinUntil1, + RepeatOne1, + RepeatOne2, + MinRepeatOne1, + MinRepeatOne2, + AtomicGroup1, + PossessiveRepeat1, + PossessiveRepeat2, + PossessiveRepeat3, + PossessiveRepeat4, +} + +fn _match<S: StrDrive>(req: &Request<'_, S>, state: &mut State, mut ctx: MatchContext) -> bool { + let mut context_stack = vec![]; + let mut popped_result = false; + + #[allow( + clippy::never_loop, + reason = "'result loop is not an actual loop but break label" + )] + 'coro: loop { + popped_result = 'result: loop { + let yielded = 'context: loop { + match ctx.jump { + Jump::OpCode => {} + Jump::Assert1 => { + if popped_result { + ctx.skip_code_from(req, 1); + } else { + break 'result false; + } + } + Jump::AssertNot1 => { + if popped_result { + break 'result false; + } + state.marks.pop(); + ctx.skip_code_from(req, 1); + } + Jump::Branch1 => { + let branch_offset = ctx.count as usize; + let next_length = ctx.peek_code(req, branch_offset) as isize; + if next_length == 0 { + state.marks.pop_discard(); + break 'result false; + } + state.cursor = ctx.cursor; + let next_ctx = ctx.next_offset(branch_offset + 1, Jump::Branch2); + ctx.count += next_length; + break 'context next_ctx; + } + Jump::Branch2 => { + if popped_result { + break 'result true; + } + state.marks.pop_keep(); + ctx.jump = Jump::Branch1; + continue 'context; + } + Jump::Repeat1 => { + state.repeat_stack.pop(); + break 'result popped_result; + } + Jump::UntilBacktrace => { + if !popped_result { + state.repeat_stack[ctx.repeat_ctx_id].count -= 1; + state.cursor = ctx.cursor; + } + break 'result popped_result; + } + Jump::MaxUntil2 => { + let save_last_position = ctx.count as usize; + let repeat_ctx = &mut state.repeat_stack[ctx.repeat_ctx_id]; + repeat_ctx.last_position = save_last_position; + + if popped_result { + state.marks.pop_discard(); + break 'result true; + } + + state.marks.pop(); + repeat_ctx.count -= 1; + state.cursor = ctx.cursor; + + /* cannot match more repeated items here. make sure the + tail matches */ + let mut next_ctx = ctx.next_offset(1, Jump::MaxUntil3); + next_ctx.repeat_ctx_id = repeat_ctx.prev_id; + break 'context next_ctx; + } + Jump::MaxUntil3 => { + if !popped_result { + state.cursor = ctx.cursor; + } + break 'result popped_result; + } + Jump::MinUntil1 => { + if popped_result { + break 'result true; + } + ctx.repeat_ctx_id = ctx.count as usize; + let repeat_ctx = &mut state.repeat_stack[ctx.repeat_ctx_id]; + state.cursor = ctx.cursor; + state.marks.pop(); + + // match more until tail matches + if repeat_ctx.count as usize >= repeat_ctx.max_count + && repeat_ctx.max_count != MAXREPEAT + || state.cursor.position == repeat_ctx.last_position + { + repeat_ctx.count -= 1; + break 'result false; + } + + /* zero-width match protection */ + repeat_ctx.last_position = state.cursor.position; + + break 'context ctx + .next_at(repeat_ctx.code_position + 4, Jump::UntilBacktrace); + } + Jump::RepeatOne1 => { + let min_count = ctx.peek_code(req, 2) as isize; + let next_code = ctx.peek_code(req, ctx.peek_code(req, 1) as usize + 1); + if next_code == SreOpcode::LITERAL as u32 { + // Special case: Tail starts with a literal. Skip positions where + // the rest of the pattern cannot possibly match. + let c = ctx.peek_code(req, ctx.peek_code(req, 1) as usize + 2); + while ctx.at_end(req) || ctx.peek_char::<S>() != c { + if ctx.count <= min_count { + state.marks.pop_discard(); + break 'result false; + } + ctx.back_advance_char::<S>(); + ctx.count -= 1; + } + } + + state.cursor = ctx.cursor; + // General case: backtracking + break 'context ctx.next_peek_from(1, req, Jump::RepeatOne2); + } + Jump::RepeatOne2 => { + if popped_result { + break 'result true; + } + + let min_count = ctx.peek_code(req, 2) as isize; + if ctx.count <= min_count { + state.marks.pop_discard(); + break 'result false; + } + + ctx.back_advance_char::<S>(); + ctx.count -= 1; + + state.marks.pop_keep(); + ctx.jump = Jump::RepeatOne1; + continue 'context; + } + Jump::MinRepeatOne1 => { + let max_count = ctx.peek_code(req, 3) as usize; + if max_count == MAXREPEAT || ctx.count as usize <= max_count { + state.cursor = ctx.cursor; + break 'context ctx.next_peek_from(1, req, Jump::MinRepeatOne2); + } else { + state.marks.pop_discard(); + break 'result false; + } + } + Jump::MinRepeatOne2 => { + if popped_result { + break 'result true; + } + + state.cursor = ctx.cursor; + + let mut count_ctx = ctx; + count_ctx.skip_code(4); + if _count(req, state, &mut count_ctx, 1) == 0 { + state.marks.pop_discard(); + break 'result false; + } + + ctx.advance_char::<S>(); + ctx.count += 1; + state.marks.pop_keep(); + ctx.jump = Jump::MinRepeatOne1; + continue 'context; + } + Jump::AtomicGroup1 => { + if popped_result { + ctx.skip_code_from(req, 1); + ctx.cursor = state.cursor; + // dispatch opcode + } else { + state.cursor = ctx.cursor; + break 'result false; + } + } + Jump::PossessiveRepeat1 => { + let min_count = ctx.peek_code(req, 2) as isize; + if ctx.count < min_count { + // modified next.toplevel from inherited to false + let mut next = ctx.next_offset(4, Jump::PossessiveRepeat2); + next.toplevel = false; + break 'context next; + } + // zero match protection + ctx.cursor.position = usize::MAX; + ctx.jump = Jump::PossessiveRepeat3; + continue 'context; + } + Jump::PossessiveRepeat2 => { + if popped_result { + ctx.count += 1; + ctx.jump = Jump::PossessiveRepeat1; + continue 'context; + } else { + state.cursor = ctx.cursor; + break 'result false; + } + } + Jump::PossessiveRepeat3 => { + let max_count = ctx.peek_code(req, 3) as usize; + if ((ctx.count as usize) < max_count || max_count == MAXREPEAT) + && ctx.cursor.position != state.cursor.position + { + state.marks.push(); + ctx.cursor = state.cursor; + let mut next = ctx.next_offset(4, Jump::PossessiveRepeat4); + next.toplevel = false; // modified next.toplevel from inherited to false + break 'context next; + } + ctx.cursor = state.cursor; + ctx.skip_code_from(req, 1); + ctx.skip_code(1); + } + Jump::PossessiveRepeat4 => { + if popped_result { + state.marks.pop_discard(); + ctx.count += 1; + ctx.jump = Jump::PossessiveRepeat3; + continue 'context; + } + state.marks.pop(); + state.cursor = ctx.cursor; + ctx.skip_code_from(req, 1); + ctx.skip_code(1); + } + } + ctx.jump = Jump::OpCode; + + loop { + macro_rules! general_op_literal { + ($f:expr) => {{ + #[allow(clippy::redundant_closure_call)] + if ctx.at_end(req) || !$f(ctx.peek_code(req, 1), ctx.peek_char::<S>()) { + break 'result false; + } + ctx.skip_code(2); + ctx.advance_char::<S>(); + }}; + } + + macro_rules! general_op_in { + ($f:expr) => {{ + #[allow(clippy::redundant_closure_call)] + if ctx.at_end(req) || !$f(&ctx.pattern(req)[2..], ctx.peek_char::<S>()) + { + break 'result false; + } + ctx.skip_code_from(req, 1); + ctx.advance_char::<S>(); + }}; + } + + macro_rules! general_op_groupref { + ($f:expr) => {{ + let (group_start, group_end) = + state.marks.get(ctx.peek_code(req, 1) as usize); + let (group_start, group_end) = if group_start.is_some() + && group_end.is_some() + && group_start.unpack() <= group_end.unpack() + { + (group_start.unpack(), group_end.unpack()) + } else { + break 'result false; + }; + + let mut g_ctx = MatchContext { + cursor: req.string.create_cursor(group_start), + ..ctx + }; + + for _ in group_start..group_end { + #[allow(clippy::redundant_closure_call)] + if ctx.at_end(req) + || $f(ctx.peek_char::<S>()) != $f(g_ctx.peek_char::<S>()) + { + break 'result false; + } + ctx.advance_char::<S>(); + g_ctx.advance_char::<S>(); + } + + ctx.skip_code(2); + }}; + } + + if ctx.remaining_codes(req) == 0 { + break 'result false; + } + let opcode = ctx.peek_code(req, 0); + let opcode = SreOpcode::try_from(opcode).unwrap(); + + match opcode { + SreOpcode::FAILURE => break 'result false, + SreOpcode::SUCCESS => { + if ctx.can_success(req) { + state.cursor = ctx.cursor; + break 'result true; + } + break 'result false; + } + SreOpcode::ANY => { + if ctx.at_end(req) || ctx.at_linebreak(req) { + break 'result false; + } + ctx.skip_code(1); + ctx.advance_char::<S>(); + } + SreOpcode::ANY_ALL => { + if ctx.at_end(req) { + break 'result false; + } + ctx.skip_code(1); + ctx.advance_char::<S>(); + } + /* <ASSERT> <skip> <back> <pattern> */ + SreOpcode::ASSERT => { + let back = ctx.peek_code(req, 2) as usize; + if ctx.cursor.position < back { + break 'result false; + } + + let mut next_ctx = ctx.next_offset(3, Jump::Assert1); + next_ctx.toplevel = false; + next_ctx.back_skip_char::<S>(back); + state.cursor = next_ctx.cursor; + break 'context next_ctx; + } + /* <ASSERT_NOT> <skip> <back> <pattern> */ + SreOpcode::ASSERT_NOT => { + let back = ctx.peek_code(req, 2) as usize; + if ctx.cursor.position < back { + ctx.skip_code_from(req, 1); + continue; + } + state.marks.push(); + + let mut next_ctx = ctx.next_offset(3, Jump::AssertNot1); + next_ctx.toplevel = false; + next_ctx.back_skip_char::<S>(back); + state.cursor = next_ctx.cursor; + break 'context next_ctx; + } + SreOpcode::AT => { + let at_code = SreAtCode::try_from(ctx.peek_code(req, 1)).unwrap(); + if at(req, &ctx, at_code) { + ctx.skip_code(2); + } else { + break 'result false; + } + } + // <BRANCH> <0=skip> code <JUMP> ... <NULL> + SreOpcode::BRANCH => { + state.marks.push(); + ctx.count = 1; + ctx.jump = Jump::Branch1; + continue 'context; + } + SreOpcode::CATEGORY => { + let cat_code = SreCatCode::try_from(ctx.peek_code(req, 1)).unwrap(); + if ctx.at_end(req) || !category(cat_code, ctx.peek_char::<S>()) { + break 'result false; + } + ctx.skip_code(2); + ctx.advance_char::<S>(); + } + SreOpcode::IN => general_op_in!(charset), + SreOpcode::IN_IGNORE => { + general_op_in!(|set, c| charset(set, lower_ascii(c))) + } + SreOpcode::IN_UNI_IGNORE => { + general_op_in!(|set, c| charset(set, lower_unicode(c))) + } + SreOpcode::IN_LOC_IGNORE => general_op_in!(charset_loc_ignore), + SreOpcode::MARK => { + state + .marks + .set(ctx.peek_code(req, 1) as usize, ctx.cursor.position); + ctx.skip_code(2); + } + SreOpcode::INFO | SreOpcode::JUMP => ctx.skip_code_from(req, 1), + /* <REPEAT> <skip> <1=min> <2=max> item <UNTIL> tail */ + SreOpcode::REPEAT => { + let repeat_ctx = RepeatContext { + count: -1, + min_count: ctx.peek_code(req, 2) as usize, + max_count: ctx.peek_code(req, 3) as usize, + code_position: ctx.code_position, + last_position: usize::MAX, + prev_id: ctx.repeat_ctx_id, + }; + state.repeat_stack.push(repeat_ctx); + let repeat_ctx_id = state.repeat_stack.len() - 1; + state.cursor = ctx.cursor; + let mut next_ctx = ctx.next_peek_from(1, req, Jump::Repeat1); + next_ctx.repeat_ctx_id = repeat_ctx_id; + break 'context next_ctx; + } + SreOpcode::MAX_UNTIL => { + let repeat_ctx = &mut state.repeat_stack[ctx.repeat_ctx_id]; + state.cursor = ctx.cursor; + repeat_ctx.count += 1; + + if (repeat_ctx.count as usize) < repeat_ctx.min_count { + // not enough matches + break 'context ctx + .next_at(repeat_ctx.code_position + 4, Jump::UntilBacktrace); + } + + if ((repeat_ctx.count as usize) < repeat_ctx.max_count + || repeat_ctx.max_count == MAXREPEAT) + && state.cursor.position != repeat_ctx.last_position + { + /* we may have enough matches, but if we can + match another item, do so */ + state.marks.push(); + ctx.count = repeat_ctx.last_position as isize; + repeat_ctx.last_position = state.cursor.position; + + break 'context ctx + .next_at(repeat_ctx.code_position + 4, Jump::MaxUntil2); + } + + /* cannot match more repeated items here. make sure the + tail matches */ + let mut next_ctx = ctx.next_offset(1, Jump::MaxUntil3); + next_ctx.repeat_ctx_id = repeat_ctx.prev_id; + break 'context next_ctx; + } + SreOpcode::MIN_UNTIL => { + let repeat_ctx = state.repeat_stack.last_mut().unwrap(); + state.cursor = ctx.cursor; + repeat_ctx.count += 1; + + if (repeat_ctx.count as usize) < repeat_ctx.min_count { + // not enough matches + break 'context ctx + .next_at(repeat_ctx.code_position + 4, Jump::UntilBacktrace); + } + + state.marks.push(); + ctx.count = ctx.repeat_ctx_id as isize; + let mut next_ctx = ctx.next_offset(1, Jump::MinUntil1); + next_ctx.repeat_ctx_id = repeat_ctx.prev_id; + break 'context next_ctx; + } + /* <REPEAT_ONE> <skip> <1=min> <2=max> item <SUCCESS> tail */ + SreOpcode::REPEAT_ONE => { + let min_count = ctx.peek_code(req, 2) as usize; + let max_count = ctx.peek_code(req, 3) as usize; + + if ctx.remaining_chars(req) < min_count { + break 'result false; + } + + state.cursor = ctx.cursor; + + let mut count_ctx = ctx; + count_ctx.skip_code(4); + let count = _count(req, state, &mut count_ctx, max_count); + if count < min_count { + break 'result false; + } + ctx.cursor = count_ctx.cursor; + + let next_code = ctx.peek_code(req, ctx.peek_code(req, 1) as usize + 1); + if next_code == SreOpcode::SUCCESS as u32 && ctx.can_success(req) { + // tail is empty. we're finished + state.cursor = ctx.cursor; + break 'result true; + } + + state.marks.push(); + ctx.count = count as isize; + ctx.jump = Jump::RepeatOne1; + continue 'context; + } + /* <MIN_REPEAT_ONE> <skip> <1=min> <2=max> item <SUCCESS> tail */ + SreOpcode::MIN_REPEAT_ONE => { + let min_count = ctx.peek_code(req, 2) as usize; + if ctx.remaining_chars(req) < min_count { + break 'result false; + } + + state.cursor = ctx.cursor; + ctx.count = if min_count == 0 { + 0 + } else { + let mut count_ctx = ctx; + count_ctx.skip_code(4); + let count = _count(req, state, &mut count_ctx, min_count); + if count < min_count { + break 'result false; + } + ctx.cursor = count_ctx.cursor; + count as isize + }; + + let next_code = ctx.peek_code(req, ctx.peek_code(req, 1) as usize + 1); + if next_code == SreOpcode::SUCCESS as u32 && ctx.can_success(req) { + // tail is empty. we're finished + state.cursor = ctx.cursor; + break 'result true; + } + + state.marks.push(); + ctx.jump = Jump::MinRepeatOne1; + continue 'context; + } + SreOpcode::LITERAL => general_op_literal!(|code, c| code == c), + SreOpcode::NOT_LITERAL => general_op_literal!(|code, c| code != c), + SreOpcode::LITERAL_IGNORE => { + general_op_literal!(|code, c| code == lower_ascii(c)) + } + SreOpcode::NOT_LITERAL_IGNORE => { + general_op_literal!(|code, c| code != lower_ascii(c)) + } + SreOpcode::LITERAL_UNI_IGNORE => { + general_op_literal!(|code, c| code == lower_unicode(c)) + } + SreOpcode::NOT_LITERAL_UNI_IGNORE => { + general_op_literal!(|code, c| code != lower_unicode(c)) + } + SreOpcode::LITERAL_LOC_IGNORE => general_op_literal!(char_loc_ignore), + SreOpcode::NOT_LITERAL_LOC_IGNORE => { + general_op_literal!(|code, c| !char_loc_ignore(code, c)) + } + SreOpcode::GROUPREF => general_op_groupref!(|x| x), + SreOpcode::GROUPREF_IGNORE => general_op_groupref!(lower_ascii), + SreOpcode::GROUPREF_LOC_IGNORE => general_op_groupref!(lower_locate), + SreOpcode::GROUPREF_UNI_IGNORE => general_op_groupref!(lower_unicode), + SreOpcode::GROUPREF_EXISTS => { + let (group_start, group_end) = + state.marks.get(ctx.peek_code(req, 1) as usize); + if group_start.is_some() + && group_end.is_some() + && group_start.unpack() <= group_end.unpack() + { + ctx.skip_code(3); + } else { + ctx.skip_code_from(req, 2) + } + } + /* <ATOMIC_GROUP> <skip> pattern <SUCCESS> tail */ + SreOpcode::ATOMIC_GROUP => { + state.cursor = ctx.cursor; + let mut next_ctx = ctx.next_offset(2, Jump::AtomicGroup1); + next_ctx.toplevel = false; // modified next.toplevel from inherited to false + break 'context next_ctx; + } + /* <POSSESSIVE_REPEAT> <skip> <1=min> <2=max> pattern + <SUCCESS> tail */ + SreOpcode::POSSESSIVE_REPEAT => { + state.cursor = ctx.cursor; + ctx.count = 0; + ctx.jump = Jump::PossessiveRepeat1; + continue 'context; + } + /* <POSSESSIVE_REPEAT_ONE> <skip> <1=min> <2=max> item <SUCCESS> + tail */ + SreOpcode::POSSESSIVE_REPEAT_ONE => { + let min_count = ctx.peek_code(req, 2) as usize; + let max_count = ctx.peek_code(req, 3) as usize; + if ctx.remaining_chars(req) < min_count { + break 'result false; + } + state.cursor = ctx.cursor; + let mut count_ctx = ctx; + count_ctx.skip_code(4); + let count = _count(req, state, &mut count_ctx, max_count); + if count < min_count { + break 'result false; + } + ctx.cursor = count_ctx.cursor; + ctx.skip_code_from(req, 1); + } + SreOpcode::CHARSET + | SreOpcode::BIGCHARSET + | SreOpcode::NEGATE + | SreOpcode::RANGE + | SreOpcode::RANGE_UNI_IGNORE + | SreOpcode::SUBPATTERN => { + unreachable!("unexpected opcode on main dispatch") + } + } + } + }; + context_stack.push(ctx); + ctx = yielded; + continue 'coro; + }; + if let Some(popped_ctx) = context_stack.pop() { + ctx = popped_ctx; + } else { + break; + } + } + popped_result +} + +fn search_info_literal<const LITERAL: bool, S: StrDrive>( + req: &mut Request<'_, S>, + state: &mut State, + mut ctx: MatchContext, +) -> bool { + /* pattern starts with a known prefix */ + /* <length> <skip> <prefix data> <overlap data> */ + let len = ctx.peek_code(req, 5) as usize; + let skip = ctx.peek_code(req, 6) as usize; + let prefix = &ctx.pattern(req)[7..7 + len]; + let overlap = &ctx.pattern(req)[7 + len - 1..7 + len * 2]; + + // code_position ready for tail match + ctx.skip_code_from(req, 1); + ctx.skip_code(2 * skip); + + req.must_advance = false; + + if len == 1 { + // pattern starts with a literal character + let c = prefix[0]; + + while !ctx.at_end(req) { + // find the next matched literal + while ctx.peek_char::<S>() != c { + ctx.advance_char::<S>(); + if ctx.at_end(req) { + return false; + } + } + + req.start = ctx.cursor.position; + state.start = req.start; + state.cursor = ctx.cursor; + S::skip(&mut state.cursor, skip); + + // literal only + if LITERAL { + return true; + } + + let mut next_ctx = ctx; + next_ctx.skip_char::<S>(skip); + + if _match(req, state, next_ctx) { + return true; + } + + ctx.advance_char::<S>(); + state.marks.clear(); + } + } else { + while !ctx.at_end(req) { + let c = prefix[0]; + while ctx.peek_char::<S>() != c { + ctx.advance_char::<S>(); + if ctx.at_end(req) { + return false; + } + } + ctx.advance_char::<S>(); + if ctx.at_end(req) { + return false; + } + + let mut i = 1; + loop { + if ctx.peek_char::<S>() == prefix[i] { + i += 1; + if i != len { + ctx.advance_char::<S>(); + if ctx.at_end(req) { + return false; + } + continue; + } + + req.start = ctx.cursor.position - (len - 1); + state.reset(req, req.start); + S::skip(&mut state.cursor, skip); + // state.start = req.start; + // state.cursor = req.string.create_cursor(req.start + skip); + + // literal only + if LITERAL { + return true; + } + + let mut next_ctx = ctx; + if skip != 0 { + next_ctx.advance_char::<S>(); + } else { + next_ctx.cursor = state.cursor; + } + + if _match(req, state, next_ctx) { + return true; + } + + ctx.advance_char::<S>(); + if ctx.at_end(req) { + return false; + } + state.marks.clear(); + } + + i = overlap[i] as usize; + if i == 0 { + break; + } + } + } + } + false +} + +fn search_info_charset<S: StrDrive>( + req: &mut Request<'_, S>, + state: &mut State, + mut ctx: MatchContext, +) -> bool { + let set = &ctx.pattern(req)[5..]; + + ctx.skip_code_from(req, 1); + + req.must_advance = false; + + loop { + while !ctx.at_end(req) && !charset(set, ctx.peek_char::<S>()) { + ctx.advance_char::<S>(); + } + if ctx.at_end(req) { + return false; + } + + req.start = ctx.cursor.position; + state.start = ctx.cursor.position; + state.cursor = ctx.cursor; + + if _match(req, state, ctx) { + return true; + } + + ctx.advance_char::<S>(); + state.marks.clear(); + } +} + +#[derive(Debug, Clone, Copy)] +struct RepeatContext { + count: isize, + min_count: usize, + max_count: usize, + code_position: usize, + last_position: usize, + prev_id: usize, +} + +#[derive(Clone, Copy)] +struct MatchContext { + cursor: StringCursor, + code_position: usize, + toplevel: bool, + jump: Jump, + repeat_ctx_id: usize, + count: isize, +} + +impl MatchContext { + fn pattern<'a, S>(&self, req: &Request<'a, S>) -> &'a [u32] { + &req.pattern_codes[self.code_position..] + } + + const fn remaining_codes<S>(&self, req: &Request<'_, S>) -> usize { + req.pattern_codes.len() - self.code_position + } + + const fn remaining_chars<S>(&self, req: &Request<'_, S>) -> usize { + req.end - self.cursor.position + } + + fn peek_char<S: StrDrive>(&self) -> u32 { + S::peek(&self.cursor) + } + + fn skip_char<S: StrDrive>(&mut self, skip: usize) { + S::skip(&mut self.cursor, skip); + } + + fn advance_char<S: StrDrive>(&mut self) -> u32 { + S::advance(&mut self.cursor) + } + + fn back_peek_char<S: StrDrive>(&self) -> u32 { + S::back_peek(&self.cursor) + } + + fn back_skip_char<S: StrDrive>(&mut self, skip: usize) { + S::back_skip(&mut self.cursor, skip); + } + + fn back_advance_char<S: StrDrive>(&mut self) -> u32 { + S::back_advance(&mut self.cursor) + } + + fn peek_code<S>(&self, req: &Request<'_, S>, peek: usize) -> u32 { + req.pattern_codes[self.code_position + peek] + } + + fn try_peek_code_as<T, S>(&self, req: &Request<'_, S>, peek: usize) -> Result<T, T::Error> + where + T: TryFrom<u32>, + { + self.peek_code(req, peek).try_into() + } + + const fn skip_code(&mut self, skip: usize) { + self.code_position += skip; + } + + fn skip_code_from<S>(&mut self, req: &Request<'_, S>, peek: usize) { + self.skip_code(self.peek_code(req, peek) as usize + 1); + } + + const fn at_beginning(&self) -> bool { + // self.ctx().string_position == self.state().start + self.cursor.position == 0 + } + + const fn at_end<S>(&self, req: &Request<'_, S>) -> bool { + self.cursor.position == req.end + } + + fn at_linebreak<S: StrDrive>(&self, req: &Request<'_, S>) -> bool { + !self.at_end(req) && is_linebreak(self.peek_char::<S>()) + } + + fn at_boundary<S: StrDrive, F: FnMut(u32) -> bool>( + &self, + req: &Request<'_, S>, + mut word_checker: F, + ) -> bool { + if self.at_beginning() && self.at_end(req) { + return false; + } + let that = !self.at_beginning() && word_checker(self.back_peek_char::<S>()); + let this = !self.at_end(req) && word_checker(self.peek_char::<S>()); + this != that + } + + fn at_non_boundary<S: StrDrive, F: FnMut(u32) -> bool>( + &self, + req: &Request<'_, S>, + mut word_checker: F, + ) -> bool { + if self.at_beginning() && self.at_end(req) { + return false; + } + let that = !self.at_beginning() && word_checker(self.back_peek_char::<S>()); + let this = !self.at_end(req) && word_checker(self.peek_char::<S>()); + this == that + } + + const fn can_success<S>(&self, req: &Request<'_, S>) -> bool { + if !self.toplevel { + return true; + } + if req.match_all && !self.at_end(req) { + return false; + } + if req.must_advance && self.cursor.position == req.start { + return false; + } + true + } + + #[must_use] + fn next_peek_from<S>(&mut self, peek: usize, req: &Request<'_, S>, jump: Jump) -> Self { + self.next_offset(self.peek_code(req, peek) as usize + 1, jump) + } + + #[must_use] + const fn next_offset(&mut self, offset: usize, jump: Jump) -> Self { + self.next_at(self.code_position + offset, jump) + } + + #[must_use] + const fn next_at(&mut self, code_position: usize, jump: Jump) -> Self { + self.jump = jump; + Self { + code_position, + jump: Jump::OpCode, + count: -1, + ..*self + } + } +} + +fn at<S: StrDrive>(req: &Request<'_, S>, ctx: &MatchContext, at_code: SreAtCode) -> bool { + match at_code { + SreAtCode::BEGINNING | SreAtCode::BEGINNING_STRING => ctx.at_beginning(), + SreAtCode::BEGINNING_LINE => ctx.at_beginning() || is_linebreak(ctx.back_peek_char::<S>()), + SreAtCode::BOUNDARY => ctx.at_boundary(req, is_word), + SreAtCode::NON_BOUNDARY => ctx.at_non_boundary(req, is_word), + SreAtCode::END => { + (ctx.remaining_chars(req) == 1 && ctx.at_linebreak(req)) || ctx.at_end(req) + } + SreAtCode::END_LINE => ctx.at_linebreak(req) || ctx.at_end(req), + SreAtCode::END_STRING => ctx.at_end(req), + SreAtCode::LOC_BOUNDARY => ctx.at_boundary(req, is_loc_word), + SreAtCode::LOC_NON_BOUNDARY => ctx.at_non_boundary(req, is_loc_word), + SreAtCode::UNI_BOUNDARY => ctx.at_boundary(req, is_uni_word), + SreAtCode::UNI_NON_BOUNDARY => ctx.at_non_boundary(req, is_uni_word), + } +} + +fn char_loc_ignore(code: u32, c: u32) -> bool { + code == c || code == lower_locate(c) || code == upper_locate(c) +} + +fn charset_loc_ignore(set: &[u32], c: u32) -> bool { + let lo = lower_locate(c); + if charset(set, c) { + return true; + } + let up = upper_locate(c); + up != lo && charset(set, up) +} + +fn category(cat_code: SreCatCode, c: u32) -> bool { + match cat_code { + SreCatCode::DIGIT => is_digit(c), + SreCatCode::NOT_DIGIT => !is_digit(c), + SreCatCode::SPACE => is_space(c), + SreCatCode::NOT_SPACE => !is_space(c), + SreCatCode::WORD => is_word(c), + SreCatCode::NOT_WORD => !is_word(c), + SreCatCode::LINEBREAK => is_linebreak(c), + SreCatCode::NOT_LINEBREAK => !is_linebreak(c), + SreCatCode::LOC_WORD => is_loc_word(c), + SreCatCode::LOC_NOT_WORD => !is_loc_word(c), + SreCatCode::UNI_DIGIT => is_uni_digit(c), + SreCatCode::UNI_NOT_DIGIT => !is_uni_digit(c), + SreCatCode::UNI_SPACE => is_uni_space(c), + SreCatCode::UNI_NOT_SPACE => !is_uni_space(c), + SreCatCode::UNI_WORD => is_uni_word(c), + SreCatCode::UNI_NOT_WORD => !is_uni_word(c), + SreCatCode::UNI_LINEBREAK => is_uni_linebreak(c), + SreCatCode::UNI_NOT_LINEBREAK => !is_uni_linebreak(c), + } +} + +fn charset(set: &[u32], ch: u32) -> bool { + /* check if character is a member of the given set */ + let mut ok = true; + let mut i = 0; + while i < set.len() { + let opcode = match SreOpcode::try_from(set[i]) { + Ok(code) => code, + Err(_) => { + break; + } + }; + match opcode { + SreOpcode::FAILURE => { + return !ok; + } + SreOpcode::CATEGORY => { + /* <CATEGORY> <code> */ + let cat_code = match SreCatCode::try_from(set[i + 1]) { + Ok(code) => code, + Err(_) => { + break; + } + }; + if category(cat_code, ch) { + return ok; + } + i += 2; + } + SreOpcode::CHARSET => { + /* <CHARSET> <bitmap> */ + let set = &set[i + 1..]; + if ch < 256 && ((set[(ch >> 5) as usize] & (1u32 << (ch & 31))) != 0) { + return ok; + } + i += 1 + 8; + } + SreOpcode::BIGCHARSET => { + /* <BIGCHARSET> <block_count> <256 block_indices> <blocks> */ + let count = set[i + 1] as usize; + if ch < 0x10000 { + let set = &set[i + 2..]; + let block_index = ch >> 8; + let (_, block_indices, _) = unsafe { set.align_to::<u8>() }; + let blocks = &set[64..]; + let block = block_indices[block_index as usize]; + if blocks[((block as u32 * 256 + (ch & 255)) / 32) as usize] + & (1u32 << (ch & 31)) + != 0 + { + return ok; + } + } + i += 2 + 64 + count * 8; + } + SreOpcode::LITERAL => { + /* <LITERAL> <code> */ + if ch == set[i + 1] { + return ok; + } + i += 2; + } + SreOpcode::NEGATE => { + ok = !ok; + i += 1; + } + SreOpcode::RANGE => { + /* <RANGE> <lower> <upper> */ + if set[i + 1] <= ch && ch <= set[i + 2] { + return ok; + } + i += 3; + } + SreOpcode::RANGE_UNI_IGNORE => { + /* <RANGE_UNI_IGNORE> <lower> <upper> */ + if set[i + 1] <= ch && ch <= set[i + 2] { + return ok; + } + // CPython's Unicode ignore-case ranges include the extra + // one-code-point folds (notably dotless i and long s). + let ch = lower_unicode(upper_unicode(ch)); + if set[i + 1] <= ch && ch <= set[i + 2] { + return ok; + } + i += 3; + } + _ => { + break; + } + } + } + /* internal error -- there's not much we can do about it + here, so let's just pretend it didn't match... */ + false +} + +fn _count<S: StrDrive>( + req: &Request<'_, S>, + state: &mut State, + ctx: &mut MatchContext, + max_count: usize, +) -> usize { + let max_count = core::cmp::min(max_count, ctx.remaining_chars(req)); + let end = ctx.cursor.position + max_count; + let opcode = SreOpcode::try_from(ctx.peek_code(req, 0)).unwrap(); + + match opcode { + SreOpcode::ANY => { + while ctx.cursor.position < end && !ctx.at_linebreak(req) { + ctx.advance_char::<S>(); + } + } + SreOpcode::ANY_ALL => { + ctx.skip_char::<S>(max_count); + } + SreOpcode::IN => { + while ctx.cursor.position < end && charset(&ctx.pattern(req)[2..], ctx.peek_char::<S>()) + { + ctx.advance_char::<S>(); + } + } + SreOpcode::LITERAL => { + general_count_literal(req, ctx, end, |code, c| code == c); + } + SreOpcode::NOT_LITERAL => { + general_count_literal(req, ctx, end, |code, c| code != c); + } + SreOpcode::LITERAL_IGNORE => { + general_count_literal(req, ctx, end, |code, c| code == lower_ascii(c)); + } + SreOpcode::NOT_LITERAL_IGNORE => { + general_count_literal(req, ctx, end, |code, c| code != lower_ascii(c)); + } + SreOpcode::LITERAL_LOC_IGNORE => { + general_count_literal(req, ctx, end, char_loc_ignore); + } + SreOpcode::NOT_LITERAL_LOC_IGNORE => { + general_count_literal(req, ctx, end, |code, c| !char_loc_ignore(code, c)); + } + SreOpcode::LITERAL_UNI_IGNORE => { + general_count_literal(req, ctx, end, |code, c| code == lower_unicode(c)); + } + SreOpcode::NOT_LITERAL_UNI_IGNORE => { + general_count_literal(req, ctx, end, |code, c| code != lower_unicode(c)); + } + _ => { + /* General case */ + ctx.toplevel = false; + ctx.jump = Jump::OpCode; + ctx.repeat_ctx_id = usize::MAX; + ctx.count = -1; + + let mut sub_state = State { + marks: Marks::default(), + repeat_stack: vec![], + ..*state + }; + + while ctx.cursor.position < end && _match(req, &mut sub_state, *ctx) { + ctx.advance_char::<S>(); + } + } + } + + // TODO: return offset + ctx.cursor.position - state.cursor.position +} + +fn general_count_literal<S: StrDrive, F: FnMut(u32, u32) -> bool>( + req: &Request<'_, S>, + ctx: &mut MatchContext, + end: usize, + mut f: F, +) { + let ch = ctx.peek_code(req, 1); + while ctx.cursor.position < end && f(ch, ctx.peek_char::<S>()) { + ctx.advance_char::<S>(); + } +} diff --git a/browser/vendor/rustpython-sre_engine/src/lib.rs b/browser/vendor/rustpython-sre_engine/src/lib.rs new file mode 100644 index 000000000..e72716d38 --- /dev/null +++ b/browser/vendor/rustpython-sre_engine/src/lib.rs @@ -0,0 +1,24 @@ +#![no_std] + +extern crate alloc; + +pub mod compiler; +pub mod constants; +pub mod engine; +pub mod string; + +pub use constants::{SRE_MAGIC, SreAtCode, SreCatCode, SreFlag, SreInfo, SreOpcode}; +pub use engine::{Request, SearchIter, State}; +pub use string::{StrDrive, StringCursor}; + +pub const CODESIZE: usize = 4; + +#[cfg(target_pointer_width = "32")] +pub const MAXREPEAT: usize = usize::MAX - 1; +#[cfg(target_pointer_width = "64")] +pub const MAXREPEAT: usize = u32::MAX as usize; + +#[cfg(target_pointer_width = "32")] +pub const MAXGROUPS: usize = MAXREPEAT / 4 / 2; +#[cfg(target_pointer_width = "64")] +pub const MAXGROUPS: usize = MAXREPEAT / 2; diff --git a/browser/vendor/rustpython-sre_engine/src/string.rs b/browser/vendor/rustpython-sre_engine/src/string.rs new file mode 100644 index 000000000..61bf575d2 --- /dev/null +++ b/browser/vendor/rustpython-sre_engine/src/string.rs @@ -0,0 +1,531 @@ +use rustpython_wtf8::Wtf8; + +#[derive(Debug, Clone, Copy)] +pub struct StringCursor { + pub(crate) ptr: *const u8, + pub position: usize, +} + +impl Default for StringCursor { + fn default() -> Self { + Self { + ptr: core::ptr::null(), + position: 0, + } + } +} + +pub trait StrDrive: Copy { + fn count(&self) -> usize; + fn create_cursor(&self, n: usize) -> StringCursor; + fn adjust_cursor(&self, cursor: &mut StringCursor, n: usize); + fn advance(cursor: &mut StringCursor) -> u32; + fn peek(cursor: &StringCursor) -> u32; + fn skip(cursor: &mut StringCursor, n: usize); + fn back_advance(cursor: &mut StringCursor) -> u32; + fn back_peek(cursor: &StringCursor) -> u32; + fn back_skip(cursor: &mut StringCursor, n: usize); +} + +impl StrDrive for &[u8] { + #[inline] + fn count(&self) -> usize { + self.len() + } + + #[inline] + fn create_cursor(&self, n: usize) -> StringCursor { + StringCursor { + ptr: self[n..].as_ptr(), + position: n, + } + } + + #[inline] + fn adjust_cursor(&self, cursor: &mut StringCursor, n: usize) { + cursor.position = n; + cursor.ptr = self[n..].as_ptr(); + } + + #[inline] + fn advance(cursor: &mut StringCursor) -> u32 { + cursor.position += 1; + unsafe { cursor.ptr = cursor.ptr.add(1) }; + unsafe { *cursor.ptr as u32 } + } + + #[inline] + fn peek(cursor: &StringCursor) -> u32 { + unsafe { *cursor.ptr as u32 } + } + + #[inline] + fn skip(cursor: &mut StringCursor, n: usize) { + cursor.position += n; + unsafe { cursor.ptr = cursor.ptr.add(n) }; + } + + #[inline] + fn back_advance(cursor: &mut StringCursor) -> u32 { + cursor.position -= 1; + unsafe { cursor.ptr = cursor.ptr.sub(1) }; + unsafe { *cursor.ptr as u32 } + } + + #[inline] + fn back_peek(cursor: &StringCursor) -> u32 { + unsafe { *cursor.ptr.offset(-1) as u32 } + } + + #[inline] + fn back_skip(cursor: &mut StringCursor, n: usize) { + cursor.position -= n; + unsafe { cursor.ptr = cursor.ptr.sub(n) }; + } +} + +impl StrDrive for &str { + #[inline] + fn count(&self) -> usize { + self.chars().count() + } + + #[inline] + fn create_cursor(&self, n: usize) -> StringCursor { + let mut cursor = StringCursor { + ptr: self.as_ptr(), + position: 0, + }; + Self::skip(&mut cursor, n); + cursor + } + + #[inline] + fn adjust_cursor(&self, cursor: &mut StringCursor, n: usize) { + if cursor.ptr.is_null() || cursor.position > n { + *cursor = Self::create_cursor(self, n); + } else if cursor.position < n { + Self::skip(cursor, n - cursor.position); + } + } + + #[inline] + fn advance(cursor: &mut StringCursor) -> u32 { + cursor.position += 1; + unsafe { next_code_point(&mut cursor.ptr) } + } + + #[inline] + fn peek(cursor: &StringCursor) -> u32 { + let mut ptr = cursor.ptr; + unsafe { next_code_point(&mut ptr) } + } + + #[inline] + fn skip(cursor: &mut StringCursor, n: usize) { + cursor.position += n; + for _ in 0..n { + unsafe { next_code_point(&mut cursor.ptr) }; + } + } + + #[inline] + fn back_advance(cursor: &mut StringCursor) -> u32 { + cursor.position -= 1; + unsafe { next_code_point_reverse(&mut cursor.ptr) } + } + + #[inline] + fn back_peek(cursor: &StringCursor) -> u32 { + let mut ptr = cursor.ptr; + unsafe { next_code_point_reverse(&mut ptr) } + } + + #[inline] + fn back_skip(cursor: &mut StringCursor, n: usize) { + cursor.position -= n; + for _ in 0..n { + unsafe { next_code_point_reverse(&mut cursor.ptr) }; + } + } +} + +impl StrDrive for &Wtf8 { + #[inline] + fn count(&self) -> usize { + self.code_points().count() + } + + #[inline] + fn create_cursor(&self, n: usize) -> StringCursor { + let mut cursor = StringCursor { + ptr: self.as_bytes().as_ptr(), + position: 0, + }; + Self::skip(&mut cursor, n); + cursor + } + + #[inline] + fn adjust_cursor(&self, cursor: &mut StringCursor, n: usize) { + if cursor.ptr.is_null() || cursor.position > n { + *cursor = Self::create_cursor(self, n); + } else if cursor.position < n { + Self::skip(cursor, n - cursor.position); + } + } + + #[inline] + fn advance(cursor: &mut StringCursor) -> u32 { + cursor.position += 1; + unsafe { next_code_point(&mut cursor.ptr) } + } + + #[inline] + fn peek(cursor: &StringCursor) -> u32 { + let mut ptr = cursor.ptr; + unsafe { next_code_point(&mut ptr) } + } + + #[inline] + fn skip(cursor: &mut StringCursor, n: usize) { + cursor.position += n; + for _ in 0..n { + unsafe { next_code_point(&mut cursor.ptr) }; + } + } + + #[inline] + fn back_advance(cursor: &mut StringCursor) -> u32 { + cursor.position -= 1; + unsafe { next_code_point_reverse(&mut cursor.ptr) } + } + + #[inline] + fn back_peek(cursor: &StringCursor) -> u32 { + let mut ptr = cursor.ptr; + unsafe { next_code_point_reverse(&mut ptr) } + } + + #[inline] + fn back_skip(cursor: &mut StringCursor, n: usize) { + cursor.position -= n; + for _ in 0..n { + unsafe { next_code_point_reverse(&mut cursor.ptr) }; + } + } +} + +/// Reads the next code point out of a byte iterator (assuming a +/// UTF-8-like encoding). +/// +/// # Safety +/// +/// `bytes` must produce a valid UTF-8-like (UTF-8 or WTF-8) string +#[inline] +const unsafe fn next_code_point(ptr: &mut *const u8) -> u32 { + // Decode UTF-8 + let x = unsafe { **ptr }; + *ptr = unsafe { ptr.offset(1) }; + + if x < 128 { + return x as u32; + } + + // Multibyte case follows + // Decode from a byte combination out of: [[[x y] z] w] + // NOTE: Performance is sensitive to the exact formulation here + let init = utf8_first_byte(x, 2); + // SAFETY: `bytes` produces an UTF-8-like string, + // so the iterator must produce a value here. + let y = unsafe { **ptr }; + *ptr = unsafe { ptr.offset(1) }; + let mut ch = utf8_acc_cont_byte(init, y); + if x >= 0xE0 { + // [[x y z] w] case + // 5th bit in 0xE0 .. 0xEF is always clear, so `init` is still valid + // SAFETY: `bytes` produces an UTF-8-like string, + // so the iterator must produce a value here. + let z = unsafe { **ptr }; + *ptr = unsafe { ptr.offset(1) }; + let y_z = utf8_acc_cont_byte((y & CONT_MASK) as u32, z); + ch = (init << 12) | y_z; + if x >= 0xF0 { + // [x y z w] case + // use only the lower 3 bits of `init` + // SAFETY: `bytes` produces an UTF-8-like string, + // so the iterator must produce a value here. + let w = unsafe { **ptr }; + *ptr = unsafe { ptr.offset(1) }; + ch = ((init & 7) << 18) | utf8_acc_cont_byte(y_z, w); + } + } + + ch +} + +/// Reads the last code point out of a byte iterator (assuming a +/// UTF-8-like encoding). +/// +/// # Safety +/// +/// `bytes` must produce a valid UTF-8-like (UTF-8 or WTF-8) string +#[inline] +const unsafe fn next_code_point_reverse(ptr: &mut *const u8) -> u32 { + // Decode UTF-8 + *ptr = unsafe { ptr.offset(-1) }; + let w = match unsafe { **ptr } { + next_byte if next_byte < 128 => return next_byte as u32, + back_byte => back_byte, + }; + + // Multibyte case follows + // Decode from a byte combination out of: [x [y [z w]]] + let mut ch; + // SAFETY: `bytes` produces an UTF-8-like string, + // so the iterator must produce a value here. + *ptr = unsafe { ptr.offset(-1) }; + let z = unsafe { **ptr }; + ch = utf8_first_byte(z, 2); + if utf8_is_cont_byte(z) { + // SAFETY: `bytes` produces an UTF-8-like string, + // so the iterator must produce a value here. + *ptr = unsafe { ptr.offset(-1) }; + let y = unsafe { **ptr }; + ch = utf8_first_byte(y, 3); + if utf8_is_cont_byte(y) { + // SAFETY: `bytes` produces an UTF-8-like string, + // so the iterator must produce a value here. + *ptr = unsafe { ptr.offset(-1) }; + let x = unsafe { **ptr }; + ch = utf8_first_byte(x, 4); + ch = utf8_acc_cont_byte(ch, y); + } + ch = utf8_acc_cont_byte(ch, z); + } + ch = utf8_acc_cont_byte(ch, w); + + ch +} + +/// Returns the initial codepoint accumulator for the first byte. +/// The first byte is special, only want bottom 5 bits for width 2, 4 bits +/// for width 3, and 3 bits for width 4. +#[inline] +const fn utf8_first_byte(byte: u8, width: u32) -> u32 { + (byte & (0x7F >> width)) as u32 +} + +/// Returns the value of `ch` updated with continuation byte `byte`. +#[inline] +const fn utf8_acc_cont_byte(ch: u32, byte: u8) -> u32 { + (ch << 6) | (byte & CONT_MASK) as u32 +} + +/// Checks whether the byte is a UTF-8 continuation byte (i.e., starts with the +/// bits `10`). +#[inline] +const fn utf8_is_cont_byte(byte: u8) -> bool { + (byte as i8) < -64 +} + +/// Mask of the value bits of a continuation byte. +const CONT_MASK: u8 = 0b0011_1111; + +const fn is_py_ascii_whitespace(b: u8) -> bool { + matches!(b, b'\t' | b'\n' | b'\x0C' | b'\r' | b' ' | b'\x0B') +} + +#[inline] +pub(crate) fn is_word(ch: u32) -> bool { + ch == '_' as u32 + || u8::try_from(ch) + .map(|x| x.is_ascii_alphanumeric()) + .unwrap_or(false) +} +#[inline] +pub(crate) fn is_space(ch: u32) -> bool { + u8::try_from(ch) + .map(is_py_ascii_whitespace) + .unwrap_or(false) +} +#[inline] +pub(crate) fn is_digit(ch: u32) -> bool { + u8::try_from(ch) + .map(|x| x.is_ascii_digit()) + .unwrap_or(false) +} +#[inline] +pub(crate) fn is_loc_alnum(ch: u32) -> bool { + // FIXME: Ignore the locales + u8::try_from(ch) + .map(|x| x.is_ascii_alphanumeric()) + .unwrap_or(false) +} +#[inline] +pub(crate) fn is_loc_word(ch: u32) -> bool { + ch == '_' as u32 || is_loc_alnum(ch) +} +#[inline] +pub(crate) const fn is_linebreak(ch: u32) -> bool { + ch == '\n' as u32 +} +#[inline] +pub fn lower_ascii(ch: u32) -> u32 { + u8::try_from(ch) + .map(|x| x.to_ascii_lowercase() as u32) + .unwrap_or(ch) +} +#[inline] +pub(crate) fn lower_locate(ch: u32) -> u32 { + // FIXME: Ignore the locales + lower_ascii(ch) +} +#[inline] +pub(crate) fn upper_locate(ch: u32) -> u32 { + // FIXME: Ignore the locales + u8::try_from(ch) + .map(|x| x.to_ascii_uppercase() as u32) + .unwrap_or(ch) +} +#[inline] +pub(crate) fn is_uni_digit(ch: u32) -> bool { + const DECIMAL_RANGES: &[(u32, u32)] = &[ + (0x0030, 0x0039), + (0x0660, 0x0669), + (0x06F0, 0x06F9), + (0x07C0, 0x07C9), + (0x0966, 0x096F), + (0x09E6, 0x09EF), + (0x0A66, 0x0A6F), + (0x0AE6, 0x0AEF), + (0x0B66, 0x0B6F), + (0x0BE6, 0x0BEF), + (0x0C66, 0x0C6F), + (0x0CE6, 0x0CEF), + (0x0D66, 0x0D6F), + (0x0DE6, 0x0DEF), + (0x0E50, 0x0E59), + (0x0ED0, 0x0ED9), + (0x0F20, 0x0F29), + (0x1040, 0x1049), + (0x1090, 0x1099), + (0x17E0, 0x17E9), + (0x1810, 0x1819), + (0x1946, 0x194F), + (0x19D0, 0x19D9), + (0x1A80, 0x1A89), + (0x1A90, 0x1A99), + (0x1B50, 0x1B59), + (0x1BB0, 0x1BB9), + (0x1C40, 0x1C49), + (0x1C50, 0x1C59), + (0xA620, 0xA629), + (0xA8D0, 0xA8D9), + (0xA900, 0xA909), + (0xA9D0, 0xA9D9), + (0xA9F0, 0xA9F9), + (0xAA50, 0xAA59), + (0xABF0, 0xABF9), + (0xFF10, 0xFF19), + (0x104A0, 0x104A9), + (0x10D30, 0x10D39), + (0x11066, 0x1106F), + (0x110F0, 0x110F9), + (0x11136, 0x1113F), + (0x111D0, 0x111D9), + (0x112F0, 0x112F9), + (0x11450, 0x11459), + (0x114D0, 0x114D9), + (0x11650, 0x11659), + (0x116C0, 0x116C9), + (0x11730, 0x11739), + (0x118E0, 0x118E9), + (0x11950, 0x11959), + (0x11C50, 0x11C59), + (0x11D50, 0x11D59), + (0x11DA0, 0x11DA9), + (0x11F50, 0x11F59), + (0x16A60, 0x16A69), + (0x16AC0, 0x16AC9), + (0x16B50, 0x16B59), + (0x1D7CE, 0x1D7FF), + (0x1E140, 0x1E149), + (0x1E2F0, 0x1E2F9), + (0x1E4F0, 0x1E4F9), + (0x1E950, 0x1E959), + (0x1FBF0, 0x1FBF9), + ]; + DECIMAL_RANGES + .iter() + .any(|&(start, end)| start <= ch && ch <= end) +} +#[inline] +pub(crate) fn is_uni_space(ch: u32) -> bool { + // TODO: check with cpython + is_space(ch) + || matches!( + ch, + 0x0009 + | 0x000A + | 0x000B + | 0x000C + | 0x000D + | 0x001C + | 0x001D + | 0x001E + | 0x001F + | 0x0020 + | 0x0085 + | 0x00A0 + | 0x1680 + | 0x2000 + | 0x2001 + | 0x2002 + | 0x2003 + | 0x2004 + | 0x2005 + | 0x2006 + | 0x2007 + | 0x2008 + | 0x2009 + | 0x200A + | 0x2028 + | 0x2029 + | 0x202F + | 0x205F + | 0x3000 + ) +} +#[inline] +pub(crate) const fn is_uni_linebreak(ch: u32) -> bool { + matches!( + ch, + 0x000A | 0x000B | 0x000C | 0x000D | 0x001C | 0x001D | 0x001E | 0x0085 | 0x2028 | 0x2029 + ) +} +#[inline] +pub(crate) fn is_uni_alnum(ch: u32) -> bool { + // TODO: check with cpython + char::try_from(ch) + .map(|x| x.is_alphanumeric()) + .unwrap_or(false) +} +#[inline] +pub(crate) fn is_uni_word(ch: u32) -> bool { + ch == '_' as u32 || is_uni_alnum(ch) +} +#[inline] +pub fn lower_unicode(ch: u32) -> u32 { + // TODO: check with cpython + char::try_from(ch) + .map(|x| x.to_lowercase().next().unwrap() as u32) + .unwrap_or(ch) +} +#[inline] +pub fn upper_unicode(ch: u32) -> u32 { + // TODO: check with cpython + char::try_from(ch) + .map(|x| x.to_uppercase().next().unwrap() as u32) + .unwrap_or(ch) +} diff --git a/browser/vendor/rustpython-sre_engine/tests/differential.py b/browser/vendor/rustpython-sre_engine/tests/differential.py new file mode 100644 index 000000000..a20100278 --- /dev/null +++ b/browser/vendor/rustpython-sre_engine/tests/differential.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Deterministic CPython 3.12 differential corpus for the compatibility fork.""" + +from __future__ import annotations + +import argparse +import json +import random +import re +import subprocess +import sys +from pathlib import Path + +SEED = 0x31213 + +ATOMS = [ + "a", "b", ".", "[ab]", "[^x]", "[a-z]", "\\d", "\\D", "\\s", + "\\S", "\\w", "\\W", "\\b", "\\B", "^", "$", "é", "Ω", "中", +] +TEXTS = [ + "", "a", "b", "ab", "aba", "aaab", "123", "a1 b2", " ", "\n", + "éÉè", "ΩωΩ", "İıſK", "中a_9", "x\ny", "word-word", "١٢٣", "\u2003", +] +VALID_TEMPLATES = [ + "{}", "(?:{})", "({})", "{}*", "{}+", "{}?", "{}*?", "{}+?", "{}??", + "{}{{0}}", "{}{{1}}", "{}{{0,2}}", "{}{{1,3}}", "(?={})", "(?!{})", + "{}|b", "a|{}", "(?:{}a)?", "(?i:{})", "(?a:{})", "(?s:{})", "(?m:{})", +] +STRUCTURED = [ + "(a)?(?(1)b|c)", "(?P<x>a)(?P=x)", "(?<=ab)c", "(?<!ab)c", + "(?=(a+))a", "(a*)(b?)", "((ab)+)", "(?:a?)*", "(?:a*)*", + "(?>a*)a", "a*+a", "(?i)[a-z]+", "(?i)k", "(?i)s", "(?i)i", + "(?i)σ", "(?i)ß", "\\A.*\\Z", "^.*$", "(?:|a)", "(?:a|)", +] +ERRORS = [ + "(", "[", "*", "+", "?", "a**", "a++?", "a{4,2}", "(?", "(?P)", + "(?P<>)", "(?P<x>a)(?P<x>b)", "(?P=x)", "(?<x)", "(?<=a*)", "(?<!a+)", + "(?i", "a(?i)b", "(?L)", "(?au)", "(?i-i:a)", "(?-a:a)", "(?z:a)", + "\\", "\\x", "\\x0", "\\u123", "\\U00110000", "\\N", "\\N{}", + "\\N{NO SUCH NAME}", "\\1", "(a)\\2", "[z-a]", "[\\w-a]", "[a-\\d]", + "(?(0)a|b)", "(?(99)a|b)", "(a)(?(1)b|c|d)", ")", "a)", "{1}", +] + + +def valid_cases(rng: random.Random, count: int): + cases: list[tuple[str, str, bool]] = [] + for atom in ATOMS: + for template in VALID_TEMPLATES: + pattern = template.format(atom) + for text in TEXTS: + cases.append((pattern, text, False)) + cases.append((pattern, text, True)) + for pattern in STRUCTURED: + for text in TEXTS: + cases.append((pattern, text, False)) + cases.append((pattern, text, True)) + rng.shuffle(cases) + while len(cases) < count: + left = rng.choice(ATOMS[:16]) + right = rng.choice(ATOMS[:16]) + pattern = rng.choice([ + f"(?:{left}{right}){{0,3}}", f"({left})({right})", f"(?={left}){right}", + f"(?:{left}|{right})+", f"(?i:{left})(?a:{right})", + ]) + cases.append((pattern, rng.choice(TEXTS), bool(rng.getrandbits(1)))) + return cases[:count] + + +def error_cases(rng: random.Random, count: int): + cases: list[tuple[str, str, bool]] = [] + suffixes = ["", "a", "Ω", "(?:b)", "{2}"] + for pattern in ERRORS: + for suffix in suffixes: + cases.append((pattern + suffix, rng.choice(TEXTS), bool(rng.getrandbits(1)))) + while len(cases) < count: + pattern = rng.choice(ERRORS) + cases.append((pattern, rng.choice(TEXTS), bool(rng.getrandbits(1)))) + rng.shuffle(cases) + return cases[:count] + + +def encode_matches(pattern: re.Pattern[str], text: str) -> str: + values = pattern.findall(text) + rows: list[list[str]] = [] + for value in values: + if pattern.groups == 0: + rows.append([value]) + elif pattern.groups == 1: + rows.append([value]) + else: + rows.append(list(value)) + fields = [str(len(rows))] + fields.extend(str(len(row)) + "".join(":" + item.encode().hex() for item in row) for row in rows) + return "OK\t" + "\t".join(fields) + + +def oracle(pattern: str, text: str, ignore_case: bool) -> str: + try: + compiled = re.compile(pattern, re.I if ignore_case else 0) + except re.error as error: + return "ERR\t" + str(error).encode().hex() + return encode_matches(compiled, text) + + +def minimize(pattern: str, text: str, ignore_case: bool, driver) -> tuple[str, str]: + expected = oracle(pattern, text, ignore_case) + + def differs(pat: str, value: str) -> bool: + return query(driver, pat, value, ignore_case) != oracle(pat, value, ignore_case) + + changed = True + while changed: + changed = False + for target, value in (("pattern", pattern), ("text", text)): + for index in range(len(value)): + candidate = value[:index] + value[index + 1 :] + pat, subject = (candidate, text) if target == "pattern" else (pattern, candidate) + if differs(pat, subject): + pattern, text = pat, subject + changed = True + break + if changed: + break + return pattern, text + + +def query(driver: subprocess.Popen[str], pattern: str, text: str, ignore_case: bool) -> str: + assert driver.stdin and driver.stdout + driver.stdin.write(f"{int(ignore_case)}\t{pattern.encode().hex()}\t{text.encode().hex()}\n") + driver.stdin.flush() + response = driver.stdout.readline().rstrip("\n") + if not response: + raise RuntimeError("Rust differential driver stopped") + return response + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--cases", type=int, default=10_000) + parser.add_argument("--max-mismatches", type=int, default=20) + args = parser.parse_args() + if sys.version_info[:3] != (3, 12, 13): + parser.error(f"requires frozen CPython 3.12.13, got {sys.version.split()[0]}") + + root = Path(__file__).resolve().parents[1] + subprocess.run(["cargo", "build", "--quiet", "--example", "differential_driver"], cwd=root, check=True) + metadata = json.loads( + subprocess.check_output(["cargo", "metadata", "--format-version=1", "--no-deps"], cwd=root) + ) + executable = Path(metadata["target_directory"]) / "debug/examples/differential_driver" + driver = subprocess.Popen( + [executable], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, + ) + rng = random.Random(SEED) + valid_count = args.cases * 4 // 5 + cases = valid_cases(rng, valid_count) + error_cases(rng, args.cases - valid_count) + mismatches = [] + try: + for index, (pattern, text, ignore_case) in enumerate(cases, 1): + expected = oracle(pattern, text, ignore_case) + actual = query(driver, pattern, text, ignore_case) + if actual != expected: + minimized = minimize(pattern, text, ignore_case, driver) + mismatch = (index, pattern, text, ignore_case, expected, actual, *minimized) + if mismatch[6:] not in [item[6:] for item in mismatches]: + mismatches.append(mismatch) + if len(mismatches) >= args.max_mismatches: + break + finally: + driver.terminate() + driver.wait() + + for item in mismatches: + index, pattern, text, ignore_case, expected, actual, min_pattern, min_text = item + print(f"case={index} ignore_case={ignore_case} pattern={pattern!r} text={text!r}") + print(f" expected={expected}") + print(f" actual ={actual}") + print(f" minimized pattern={min_pattern!r} text={min_text!r}") + if mismatches: + print(f"FAILED: {len(mismatches)} distinct mismatches", file=sys.stderr) + return 1 + print(f"PASS: {len(cases)} cases (seed={SEED:#x}, valid={valid_count}, errors={len(cases)-valid_count})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/browser/vendor/scrapling-0.4.9-ad-domains.NOTICE b/browser/vendor/scrapling-0.4.9-ad-domains.NOTICE new file mode 100644 index 000000000..5fd07349c --- /dev/null +++ b/browser/vendor/scrapling-0.4.9-ad-domains.NOTICE @@ -0,0 +1,6 @@ +Scrapling 0.4.9 ad/tracker domain snapshot + +Source file: scrapling/engines/toolbelt/ad_domains.py +Source data: Peter Lowe's ad and tracking server list +Vendored list SHA-256: c05053529fa5d626774e178359db20a7628030d94075d2c12cb63957c785d387 +License: BSD-3-Clause; see SCRAPLING-0.4.9-LICENSE diff --git a/browser/vendor/scrapling-0.4.9-ad-domains.txt b/browser/vendor/scrapling-0.4.9-ad-domains.txt new file mode 100644 index 000000000..d62b13a30 --- /dev/null +++ b/browser/vendor/scrapling-0.4.9-ad-domains.txt @@ -0,0 +1,3529 @@ +# Frozen Scrapling 0.4.9 ad/tracker domains. +# Upstream data: Peter Lowe's list, frozen through Scrapling under BSD-3-Clause. +101com.com +180hits.de +180searchassistant.com +1rx.io +2020mustang.com +207.net +247media.com +24log.com +24pm-affiliation.com +2linkpath.com +2mdn.net +2o7.net +2znp09oa.com +30ads.com +3337723.com +33across.com +360yield.com +3lift.com +3o9s.short.gy +4d5.net +4info.com +4jnzhl0d0.com +50websads.com +518ad.com +6sc.co +777partner.com +77tracking.com +7bpeople.com +7cnq.net +82o9v830.com +a-ads.com +a.mktw.net +a.muloqot.uz +a.sakh.com +a.ucoz.net +a.ucoz.ru +a.vartoken.com +a.vdo.ai +a.vfghd.com +a.vfgtb.com +a.xanga.com +a11.click +a135.wftv.com +a5.overclockers.ua +aa-metrics.beauty.hotpepper.jp +aa-metrics.recruit-card.jp +aa-metrics.trip-ai.jp +aaddzz.com +aax-eu-dub.amazon.com +aaxads.com +abacho.net +abc-ads.com +ablink.comms.trainline.com +ablink.info.wise.com +ablink.news.emails-puregym.com +ablinks.mail.hinge.co +aboardlevel.com +absolutering.com +absorbingband.com +abstractedauthority.com +abtasty.com +ac.rnm.ca +accountsdoor.com +acebunny.com +acemlnb.com +acobt.tech +acridtwist.com +actionsplash.com +actonsoftware.com +actualdeals.com +actuallysheep.com +actuallysnake.com +acuityads.com +acuityplatform.com +acustomizedgift.com +ad-balancer.at +ad-balancer.net +ad-cupid.com +ad-delivery.net +ad-pay.de +ad-rotator.com +ad-score.com +ad-server.gulasidorna.se +ad-space.net +ad-up.com +ad.71i.de +ad.a8.net +ad.abcnews.com +ad.abctv.com +ad.aboutwebservices.com +ad.abum.com +ad.admitad.com +ad.allboxing.ru +ad.altervista.org +ad.amgdgt.com +ad.anuntis.com +ad.auditude.com +ad.bitmedia.io +ad.bizo.com +ad.bondage.com +ad.centrum.cz +ad.cgi.cz +ad.choiceradio.com +ad.cooks.com +ad.digitallook.com +ad.dnoticias.pt +ad.domainfactory.de +ad.exyws.org +ad.grafika.cz +ad.gt +ad.hbv.de +ad.hyena.cz +ad.iinfo.cz +ad.infoseek.com +ad.intl.xiaomi.com +ad.jetsoftware.com +ad.keenspace.com +ad.lgappstv.com +ad.liveinternet.ru +ad.lupa.cz +ad.mediastorm.hu +ad.mg +ad.missena.io +ad.musicmatch.com +ad.myapple.pl +ad.mynetreklam.com.streamprovider.net +ad.nachtagenten.de +ad.nettvservices.com +ad.nttnavi.co.jp +ad.nwt.cz +ad.period-calendar.com +ad.profiwin.de +ad.prv.pl +ad.reachlocal.com +ad.simgames.net +ad.style +ad.technoratimedia.com +ad.tv2.no +ad.universcine.com +ad.usatoday.com +ad.virtual-nights.com +ad.wavu.hu +ad.weatherbug.com +ad.wsod.com +ad.wz.cz +ad.xiaomi.com +ad.xmovies8.si +ad.xrea.com +ad.ztylez.com +ad0.bigmir.net +ad01.mediacorpsingapore.com +ad1.emule-project.org +ad1.kde.cz +ad2.iinfo.cz +ad2.lupa.cz +ad2.netriota.hu +ad2.nmm.de +ad2.xrea.com +ad3.iinfo.cz +ad3.xrea.com +ad4game.com +ad4mat.com +ad4mat.de +ad4mat.net +adabra.com +adaction.de +adadvisor.net +adalliance.io +adap.tv +adapt.tv +adbilty.me +adblade.com +adblade.org +adblockanalytics.com +adbooth.net +adbot.com +adbrite.com +adbroker.de +adbutler.com +adbuyer3.lycos.com +adcampo.com +adcannyads.com +adcash.com +adcast.deviantart.com +adcel.co +adcell.de +adcenter.net +adclick.com +adclient1.tucows.com +adclixx.net +adcolony.com +adcomplete.com +adconion.com +adcontent.gamespy.com +adcovery.com +adcycle.com +add.newmedia.cz +addfreestats.com +addme.com +addressfriend.com +ade.clmbtech.com +adecn.com +adeimptrck.com +ademails.com +adengage.com +adetracking.com +adexchangegate.com +adexchangeprediction.com +adexpose.com +adext.inkclub.com +adf.ly +adfeed.marchex.com +adflight.com +adforce.com +adform.com +adform.net +adformdsp.net +adhaven.com +adhese.be +adhese.com +adhigh.net +adhub.media +adhunter.media +adimage.guardian.co.uk +adimages.been.com +adimages.carsoup.com +adimages.go.com +adimages.homestore.com +adimages.omroepzeeland.nl +adimages.sanomawsoy.fi +adimg.com.com +adimg.uimserv.net +adimg1.chosun.com +adimgs.sapo.pt +adingo.jp +adinjector.net +adinterax.com +adisfy.com +adition.com +adition.de +adition.net +adizio.com +adjix.com +adjug.com +adjuggler.com +adjuggler.yourdictionary.com +adjust.com +adjustnetwork.com +adk2.com +adland.ru +adlegend.com +adlightning.com +adlog.com.com +adloox.com +adlooxtracking.com +adlure.net +adm.fwmrm.net +admailtiser.com +adman.gr +adman.otenet.gr +admanagement.ch +admanager.btopenworld.com +admanager.carsoup.com +admanmedia.com +admantx.com +admarketplace.net +admarvel.com +admaster.com.cn +admatchly.com +admedia.com +admeld.com +admeridianads.com +admex.com +admidadsp.com +adminder.com +adminshop.com +admixer.net +admized.com +admob.com +admonitor.com +adn.lrb.co.uk +adnami.io +adnet.asahi.com +adnet.biz +adnet.de +adnet.ru +adnetasia.com +adnetwork.net +adnetworkperformance.com +adnews.maddog2000.de +adnium.com +adnxs-simple.com +adnxs.com +adocean.pl +adonspot.com +adopsboost.com +adoptum.net +adoric-om.com +adorigin.com +adotmob.com +adpepper.dk +adpepper.nl +adperium.com +adpia.vn +adplxmd.com +adprofits.ru +adpushup.com +adrazzi.com +adreactor.com +adrecover.com +adrecreate.com +adremedy.com +adreporting.com +adrevolver.com +adriver.ru +adrolays.de +adrotate.de +adrotic.girlonthenet.com +adrta.com +ads-backend.chaincliq.com +ads-bilek.com +ads-dev.pinterest.com +ads-game-187f4.firebaseapp.com +ads-img.mozilla.org +ads-portal-cdn.vidaatv.net +ads-twitter.com +ads.365.mk +ads.5ci.lt +ads.73dpi.com +ads.aavv.com +ads.abovetopsecret.com +ads.aceweb.net +ads.acpc.cat +ads.acrosspf.com +ads.activestate.com +ads.adfox.ru +ads.administrator.de +ads.adred.de +ads.adstream.com.ro +ads.adultfriendfinder.com +ads.advance.net +ads.adverline.com +ads.alive.com +ads.alt.com +ads.amdmb.com +ads.amigos.com +ads.annabac.com +ads.apn.co.nz +ads.appsgeyser.com +ads.as4x.tmcs.net +ads.as4x.tmcs.ticketmaster.com +ads.asiafriendfinder.com +ads.avazu.net +ads.bb59.ru +ads.betfair.com +ads.bigchurch.com +ads.bigfoot.com +ads.bing.com +ads.bittorrent.com +ads.blog.com +ads.bluemountain.com +ads.boerding.com +ads.boylesports.com +ads.canalblog.com +ads.casinocity.com +ads.casumoaffiliates.com +ads.cbc.ca +ads.cc +ads.cc-dt.com +ads.centraliprom.com +ads.channel4.com +ads.cheabit.com +ads.citymagazine.si +ads.clasificadox.com +ads.co.com +ads.colombiaonline.com +ads.com.com +ads.comeon.com +ads.creative-serving.com +ads.cybersales.cz +ads.dada.it +ads.dailycamera.com +ads.deltha.hu +ads.dennisnet.co.uk +ads.desmoinesregister.com +ads.deviantart.com +ads.devmates.com +ads.digital-digest.com +ads.digitalmedianet.com +ads.digitalpoint.com +ads.doit.com.cn +ads.domeus.com +ads.eagletribune.com +ads.easy-forex.com +ads.economist.com +ads.elcarado.com +ads.electrocelt.com +ads.elitetrader.com +ads.emdee.ca +ads.emirates.net.ae +ads.epi.sk +ads.epltalk.com +ads.eu.msn.com +ads.fairfax.com.au +ads.fastcomgroup.it +ads.femmefab.nl +ads.ferianc.com +ads.filmup.com +ads.financialcontent.com +ads.flooble.com +ads.fool.com +ads.footymad.net +ads.forbes.net +ads.formit.cz +ads.fortunecity.com +ads.fotosidan.se +ads.friendfinder.com +ads.gamecity.net +ads.gamespyid.com +ads.gamigo.de +ads.gaming-universe.de +ads.gaming1.com +ads.getlucky.com +ads.gld.dk +ads.gmodules.com +ads.goyk.com +ads.gradfinder.com +ads.grindinggears.com +ads.gsm-exchange.com +ads.gsmexchange.com +ads.guardian.co.uk +ads.guardianunlimited.co.uk +ads.hbv.de +ads.hearstmags.com +ads.heartlight.org +ads.hollywood.com +ads.horsehero.com +ads.hsoub.com +ads.ibest.com.br +ads.ibryte.com +ads.icq.com +ads.ign.com +ads.imagistica.com +ads.imgur.com +ads.independent.com.mt +ads.infi.net +ads.internic.co.il +ads.ipowerweb.com +ads.itv.com +ads.jewishfriendfinder.com +ads.jobsite.co.uk +ads.justhungry.com +ads.kabooaffiliates.com +ads.kaktuz.net +ads.kelbymediagroup.com +ads.kinxxx.com +ads.kompass.com +ads.krawall.de +ads.leovegas.com +ads.lesbianpersonals.com +ads.liberte.pl +ads.linkedin.com +ads.livenation.com +ads.ma7.tv +ads.mail.bg +ads.massinfra.nl +ads.mcafee.com +ads.mediaodyssey.com +ads.mediasmart.es +ads.medienhaus.de +ads.meetcelebs.com +ads.mgnetwork.com +ads.miarroba.com +ads.mic.com +ads.mmania.com +ads.mobilebet.com +ads.mozilla.org +ads.msn.com +ads.multimania.lycos.fr +ads.muslimehelfen.org +ads.mvscoelho.com +ads.myadv.org +ads.ndtv1.com +ads.networksolutions.com +ads.newgrounds.com +ads.newmedia.cz +ads.newsint.co.uk +ads.newsquest.co.uk +ads.nj.com +ads.nola.com +ads.nordichardware.com +ads.nordichardware.se +ads.nyi.net +ads.nytimes.com +ads.nyx.cz +ads.nzcity.co.nz +ads.o2.pl +ads.oddschecker.com +ads.okcimg.com +ads.ole.com +ads.oneplace.com +ads.opensubtitles.org +ads.optusnet.com.au +ads.outpersonals.com +ads.oxyshop.cz +ads.passion.com +ads.paymonex.net +ads.pexi.nl +ads.pfl.ua +ads.phpclasses.org +ads.pinterest.com +ads.planet.nl +ads.pni.com +ads.pof.com +ads.powweb.com +ads.printscr.com +ads.prisacom.com +ads.prod.webservices.mozgcp.net +ads.program3.com +ads.psd2html.com +ads.pubmatic.com +ads.quoka.de +ads.radio1.lv +ads.recoletos.es +ads.rediff.com +ads.redlightcenter.com +ads.revjet.com +ads.reward-hunt.com +ads.samsung.com +ads.saymedia.com +ads.schmoozecom.net +ads.scifi.com +ads.seniorfriendfinder.com +ads.servebom.com +ads.shizmoo.com +ads.shopstyle.com +ads.sift.co.uk +ads.sjon.info +ads.smartclick.com +ads.socialtheater.com +ads.soft32.com +ads.soweb.gr +ads.space.com +ads.sun.com +ads.suomiautomaatti.com +ads.supplyframe.com +ads.syscdn.de +ads.themovienation.com +ads.thestar.com +ads.thrillsaffiliates.com +ads.tiktok.com +ads.tmcs.net +ads.todoti.com.br +ads.toplayaffiliates.com +ads.townhall.com +ads.travelaudience.com +ads.trinitymirror.co.uk +ads.tripod.com +ads.tripod.lycos.co.uk +ads.tripod.lycos.es +ads.tripod.lycos.it +ads.tripod.lycos.nl +ads.tso.dennisnet.co.uk +ads.twitter.com +ads.twojatv.info +ads.ultimate-guitar.com +ads.uncrate.com +ads.unison.bg +ads.usatoday.com +ads.uxs.at +ads.v-lazer.com +ads.verticalresponse.com +ads.vgchartz.com +ads.virtual-nights.com +ads.virtuopolitan.com +ads.vnumedia.com +ads.walkiberia.com +ads.watson.ch +ads.weather.ca +ads.web.de +ads.webinak.sk +ads.webmasterpoint.org +ads.whoishostingthis.com +ads.wiezoekje.nl +ads.wikia.nocookie.net +ads.wineenthusiast.com +ads.wwe.biz +ads.xhamster.com +ads.xtra.co.nz +ads.yahoo.com +ads.yimg.com +ads.yldmgrimg.net +ads.youtube.com +ads.yumenetworks.com +ads1-adnow.com +ads1.msn.com +ads1.virtual-nights.com +ads10.speedbit.com +ads180.com +ads2.brazzers.com +ads2.contentabc.com +ads2.femmefab.nl +ads2.gamecity.net +ads2.hsoub.com +ads2.virtual-nights.com +ads2.xnet.cz +ads2004.treiberupdate.de +ads24h.net +ads3-adnow.com +ads3.contentabc.com +ads3.gamecity.net +ads3.virtual-nights.com +ads4.gamecity.net +ads4.virtual-nights.com +ads4homes.com +ads5.virtual-nights.com +ads6.gamecity.net +ads7.gamecity.net +adsafeprotected.com +adsatt.abc.starwave.com +adsatt.abcnews.starwave.com +adsatt.espn.go.com +adsatt.espn.starwave.com +adsatt.go.starwave.com +adsby.bidtheatre.com +adsbydelema.com +adscale.de +adscholar.com +adscience.nl +adsco.re +adscpm.com +adsdaq.com +adsdk.yandex.ru +adsend.de +adsensecustomsearchads.com +adserve.ams.rhythmxchange.com +adserve.gkeurope.de +adserve.io +adserve.jbs.org +adserver.71i.de +adserver.adultfriendfinder.com +adserver.adverty.com +adserver.anawe.cz +adserver.ariase.org +adserver.bdoce.cl +adserver.betandwin.de +adserver.bing.com +adserver.bizedge.com +adserver.bizhat.com +adserver.break-even.it +adserver.cams.com +adserver.cdnstream.com +adserver.cherryfind.co.uk +adserver.com +adserver.diariodosertao.com.br +adserver.digitoday.com +adserver.echdk.pl +adserver.friendfinder.com +adserver.generationiron.com +adserver.hwupgrade.it +adserver.ilango.de +adserver.industryarena.com +adserver.info7.mx +adserver.irishwebmasterforum.com +adserver.janes.com +adserver.kontent.com +adserver.mobi +adserver.news.com.au +adserver.nydailynews.com +adserver.o2.pl +adserver.oddschecker.com +adserver.omroepzeeland.nl +adserver.otthonom.hu +adserver.pampa.com.br +adserver.piksel.mk +adserver.pl +adserver.portugalmail.net +adserver.pressboard.ca +adserver.sanomawsoy.fi +adserver.sciflicks.com +adserver.scr.sk +adserver.smgfiles.com +adserver.trojaner-info.de +adserver.tupolska.com +adserver.twitpic.com +adserver.virginmedia.com +adserver.waggonerguide.com +adserver01.de +adserverplus.com +adserverpub.com +adserversolutions.com +adserverxxl.de +adservice.google.com +adservice.google.com.mt +adserving.unibet.com +adservingfront.com +adservrs.com +adservrs.com.edgekey.net +adsfac.eu +adsfac.net +adsfac.us +adsflowprime.top +adshrink.it +adside.com +adsiduous.com +adskeeper.co.uk +adskeeper.com +adsklick.de +adsmart.net +adsmartracker.com +adsmetadata.startappservice.com +adsmogo.com +adsnative.com +adsoftware.com +adsolut.in +adspeed.net +adspirit.de +adsponse.de +adspredictiv.com +adspsp.com +adsroller.com +adsrv.deviantart.com +adsrv.eacdn.com +adsrv.iol.co.za +adsrv.kobi.tv +adsrv.moebelmarkt.tv +adsrv2.swidnica24.pl +adsrvr.org +adstacks.in +adstanding.com +adstat.4u.pl +adstest.weather.com +adstub.net +adsupply.com +adswizz.com +adsxyz.com +adsynergy.com +adsys.townnews.com +adsystem.simplemachines.org +adt598.com +adtech.com +adtech.de +adtechjp.com +adtechus.com +adtegrity.net +adthis.com +adthrive.com +adtiger.de +adtilt.com +adtng.com +adtology.com +adtoma.com +adtrace.org +adtrack.voicestar.com +adtraction.com +adtrade.net +adultadvertising.com +adv-adserver.com +adv.donejty.pl +adv.freeonline.it +adv.hwupgrade.it +adv.mpvc.it +adv.nexthardware.com +adv.webmd.com +adv.wp.pl +adv.yo.cz +advangelists.com +advariant.com +adventory.com +adventurousamount.com +advert.bayarea.com +advert.dyna.ultraweb.hu +adverticum.com +adverticum.net +advertipros.com +advertise.com +advertiserurl.com +advertising.com +advertisingbanners.com +advertisingbox.com +advertmarket.com +advertmedia.de +advertpro.ya.com +advertserve.com +advertwizard.com +advideo.uimserv.net +adview.com +advisormedia.cz +adviva.net +advnt.com +adwebone.com +adwhirl.com +adworldnetwork.com +adworx.at +adworx.nl +adx.gayboy.at +adxpansion.com +adxpose.com +adyoulike.com +adz.rashflash.com +adzbazar.com +adzerk.net +adzerk.s3.amazonaws.com +adzestocp.com +aerialapps.com +aerserv.com +af-ad.co.uk +affec.tv +affectionknit.com +affili.net +affiliate.1800flowers.com +affiliate.dtiserv.com +affiliate.travelnow.com +affiliate.treated.com +affiliatefuture.com +affiliates.allposters.com +affiliates.babylon.com +affiliates.globat.com +affiliates.streamray.com +affiliates.thinkhost.net +affiliates.thrixxx.com +affiliates.ultrahosting.com +affiliatetracking.com +affiliatetracking.net +affiliatewindow.com +afflnx.com +afftracking.justanswer.com +afraidlanguage.com +agencysignals.com +agkn.com +ah-ha.com +ahalogy.com +aheadday.com +aim4media.com +airplanecoffee.com +airpush.com +aistat.net +ak0gsh40.com +alchemist.go2cloud.org +alclick.com +alenty.com +alexa-sitestats.s3.amazonaws.com +algorix.co +aliasanvil.com +alikeaddition.com +alipromo.com +alluringbucket.com +aloofmetal.com +aloofvest.com +alphonso.tv +als-svc.nytimes.com +amazingcounters.com +amazon-adsystem.com +americash.com +amidsttruly.com +amung.us +analytics-production.hapyak.com +analytics.adpost.org +analytics.algoepico.net +analytics.bitrix.info +analytics.cloudron.io +analytics.ext.go-tellm.com +analytics.google.com +analytics.htmedia.in +analytics.icons8.com +analytics.inlinemanual.com +analytics.jst.ai +analytics.justuno.com +analytics.lucid.app +analytics.mailmunch.co +analytics.mobile.yandex.net +analytics.momentum-institut.at +analytics.myfinance.com +analytics.ostr.io +analytics.phando.com +analytics.picsart.com +analytics.pinterest.com +analytics.pointdrive.linkedin.com +analytics.poolshool.com +analytics.posttv.com +analytics.santander.co.uk +analytics.swiggy.com +analytics.tiktok.com +analytics.xelondigital.com +analytics.yahoo.com +analyticsapi.happypancake.net +ancientact.com +androiddownload.net +aniview.com +annonser.dagbladet.no +annoyedairport.com +annoyingacoustics.com +anrdoezrs.net +anstrex.com +anxiousapples.com +api.affiliations.site +api.amplitude.com +api.appmetrica.yandex.ru +api.eu.amplitude.com +api.intensifier.de +api.iterable.com +api.kameleoon.com +api.lab.amplitude.com +api.rudderlabs.com +api.swetrix.com +api2.amplitude.com +apolloprogram.io +app-analytics-v2.snapchat.com +app-analytics.snapchat.com +app-measurement.com +app.pendo.io +app2.salesmanago.pl +appboycdn.com +applovin.com +appsflyer.com +aps.hearstnp.com +apsalar.com +aptabase.com +apture.com +apu.samsungelectronics.com +aquaticowl.com +aralego.com +arc1.msn.com +archswimming.com +ard.xxxblackbook.com +aromamirror.com +as.webmd.com +as2.adserverhd.com +aserv.motorsgate.com +asewlfjqwlflkew.com +askdriver.com +aso1.net +assets1.exgfnetwork.com +assoc-amazon.com +aswpapius.com +aswpsdkus.com +async.gsyndication.com +at-adserver.alltop.com +at-o.net +atdmt.com +athena-ads.wikia.com +ato.mx +attractionbanana.com +attribution.report +atwola.com +auctionads.com +auctionads.net +aud.pubmatic.com +audience.media +audience2media.com +audienceinsights.com +audio.ad +audit.median.hu +audit.webinform.hu +augur.io +auto-bannertausch.de +avalonalbum.com +avazutracking.net +avenuea.com +avocet.io +awempire.com +awin1.com +awstrack.me +awzbijw.com +axonix.com +ay.delivery +ayads.co +b-s.tercept.com +b.videoamp.com +b3.videoamp.com +ba.afl.rakuten.co.jp +backbeatmedia.com +bagsurprise.com +banik.redigy.cz +banner.ambercoastcasino.com +banner.buempliz-online.ch +banner.cotedazurpalace.com +banner.easyspace.com +banner.elisa.net +banner.finzoom.ro +banner.goldenpalace.com +banner.inyourpocket.com +banner.linux.se +banner.media-system.de +banner.nixnet.cz +banner.noblepoker.com +banner.northsky.com +banner.reinstil.de +banner.tanto.de +banner.titan-dsl.de +banner10.zetasystem.dk +bannerads.de +bannerboxes.com +bannerconnect.com +bannerconnect.net +bannergrabber.internet.gr +bannerimage.com +bannermall.com +bannermanager.bnr.bg +bannerpower.com +banners.adultfriendfinder.com +banners.amigos.com +banners.asiafriendfinder.com +banners.babylon-x.com +banners.bol.com.br +banners.cams.com +banners.czi.cz +banners.dine.com +banners.direction-x.com +banners.freett.com +banners.friendfinder.com +banners.getiton.com +banners.iq.pl +banners.passion.com +banners.payserve.com +banners.resultonline.com +banners.sys-con.com +banners.thomsonlocal.com +banners.virtuagirlhd.com +bannerserver.com +bannershotlink.perfectgonzo.com +bannersng.yell.com +bannerspace.com +bannerswap.com +bannertrack.net +bannery.cz +bannieres.wdmedia.net +bans.bride.ru +baremetrics.com +barnesandnoble.bfast.com +basebanner.com +basketballbelieve.com +baskettexture.com +bastingart.com +bat.bing.com +bbelements.com +bbn.img.com.ua +beachfront.com +beacon.gu-web.net +beacon.netflix.com +beacons.gcp.gvt2.com +beacons.gvt2.com +bebi.com +beemray.com +begun.ru +behavioralengine.com +belstat.com +belstat.nl +benefits.sovendus.com +benfly.net +berp.com +bespoke.iln8.net +bestboundary.com +bestbuy.7tiv.net +bewilderedblade.com +bfmio.com +bhcumsc.com +bid.pubmatic.com +bidbarrel.cbsnews.com +bidclix.com +bidclix.net +bidpapers.com +bidr.io +bidsopt.com +bidswitch.net +bidtellect.com +bidvertiser.com +big-bang-ads.com +bigbangmedia.com +bigclicks.com +bigfishapparel.com +bigreal.org +bigworldfashion.com +billboard.cz +billtable.com +birthdaybelief.com +bitmedianetwork.com +bizible.com +bizographics.com +bizrate.com +bizzclick.com +bkrtx.com +bleachbubble.com +blingbucks.com +blis.com +blockadblock.com +blogads.com +blogcounter.de +blogherads.com +blogtoplist.se +blogtopsites.com +blueconic.com +blueconic.net +bluekai.com +bluelithium.com +bluenest.net +bluewhaleweb.com +blushingbeast.com +blushingbread.com +bm.annonce.cz +bn.bfast.com +bnrs.ilm.ee +boffoadsapi.com +boilingbeetle.com +boilingmadsoup.com +bongacash.com +boomads.com +boomtrain.com +boredcrown.com +boudja.com +bounceads.net +bounceexchange.com +bowie-cdn.fathomdns.com +box.anchorfree.net +bpath.com +bpu.samsungelectronics.com +braincash.com +brand-display.com +brandreachsys.com +brandybison.com +braze.eu +breadbalance.com +breakableinsurance.com +breaktime.com.tw +brealtime.com +bridgetrack.com +brightcom.com +brightinfo.com +brightmountainmedia.com +broadcastbed.com +broadstreetads.com +browser-http-intake.logs.datadoghq.com +browser-http-intake.logs.datadoghq.eu +bs.yandex.ru +btglss.net +btrll.com +bttrack.com +bubblyaction.com +buddycanvas.com +buysellads.com +buzzonclick.com +bwp.download.com +c.bigmir.net +c1exchange.com +c212.net +cakesdrum.com +calculatingcircle.com +calculatorstatement.com +call-ad-network-api.marchex.com +callousbrake.com +callrail.com +calmcactus.com +calypsocapsule.com +campaign.bharatmatrimony.com +caniamedia.com +capriciouscorn.com +captainbicycle.com +captainstick.com +carambo.la +carbonads.com +carbonads.net +cardmethod.com +caringcast.com +carsarace.com +cartstack.com +carvecakes.com +casalemedia.com +casalmedia.com +cash4members.com +cash4popup.de +cashcrate.com +cashengines.com +cashfiesta.com +cashpartner.com +cashstaging.me +casinopays.com +casinorewards.com +casinotraffic.com +cattlecommittee.com +causecherry.com +cautiouscredit.com +cbanners.virtuagirlhd.com +cbzxy.com +cdn.bannerflow.com +cdn.branch.io +cdn.freshmarketer.com +cdn.heapanalytics.com +cdn.keywee.co +cdn.mouseflow.com +cdn.onesignal.com +cdn.scarabresearch.com +cdn.segment.com +cdnondemand.org +cedato.com +celtra.com +centerpointmedia.com +cetrk.com +cgicounter.puretec.de +chairscrack.com +channelintelligence.com +chargecracker.com +chart.dk +chartbeat.com +chartbeat.net +chartboost.com +checkstat.nl +cherriescare.com +childlikecrowd.com +chubbycreature.com +citrusad.net +cityads.telus.net +cj.com +cjbmanagement.com +cjlog.com +cl.turkishairlines.com +cl0udh0st1ng.com +clck.ru +clevernt.com +click-1.pl +click.airmalta-mail.com +click.aliexpress.com +click.allkeyshop.com +click.bkdpt.com +click.cartsguru.io +click.ccg.nintendo.com +click.cision.com +click.classmates.com +click.comm.rcibank.co.uk +click.crm.ba.com +click.digital.metaquestmail.com +click.discord.com +click.e.bbcmail.co.uk +click.e.progressive.com +click.e.zoom.us +click.em.blizzard.com +click.email.bbc.com +click.email.lhh.com +click.email.microsoftemail.com +click.email.sonos.com +click.emails.argos.co.uk +click.emails.tuclothing.sainsburys.co.uk +click.fool.com +click.hookupinyourcity.com +click.hooligapps.com +click.i.southwesternrailway.com +click.infoblox.com +click.justwatch.com +click.kmindex.ru +click.liftoff.io +click.mail.hotels.com +click.mail.salesforce.com +click.mailing.ticketmaster.com +click.mkt.grab.com +click.news.vans.com +click.nl.npr.org +click.nvgaming.nvidia.com +click.redditmail.com +click.uber.com +click.v.visionlab.es +click2freemoney.com +click360v2-ingest.azurewebsites.net +click4.pro +clickadddilla.com +clickadz.com +clickagents.com +clickbank.com +clickbooth.com +clickboothlnk.com +clickbrokers.com +clickcease.com +clickcompare.co.uk +clickdensity.com +clickedyclick.com +clickfuse.com +clickhereforcellphones.com +clickngo.pro +clickngo.top +clickonometrics.pl +clicks.checkatrade.com +clicks.deliveroo.co.uk +clicks.equantum.com +clicks.eventbrite.com +clicks.monzo.com +clickserve.cc-dt.com +clicksinfo.thefork.co.uk +clicktag.de +clickthruserver.com +clickthrutraffic.com +clicktrack.pubmatic.com +clicktrack.ziyu.net +clicktracks.com +clicktrade.com +clickxchange.com +clickyab.com +clickz.com +clientgear.com +clientmetrics-pa.googleapis.com +clikerz.net +cliksolution.com +clixgalore.com +clk1005.com +clk1011.com +clk1015.com +clkrev.com +clksite.com +cloudflareinsights.com +cloudguppy.com +clrstm.com +cluster.adultworld.com +clustrmaps.com +cmp.dmgmediaprivacy.co.uk +cmvrclicks000.com +cnomy.com +cnt1.pocitadlo.cz +cnvlink.com +cny.yoyo.org +codeadnetwork.com +cognitiv.ai +cointraffic.io +coldbalance.com +collector-dev.cdp-dev.cnn.com +collector.cdp.cnn.com +colonize.com +colorfulpet.com +coloroptimizer.com +comfortablecheese.com +commissionmonster.com +communications.melitaltd.com +compactbanner.com +comparereaction.com +compilework.com +comprabanner.it +conditionchange.com +conductrics.com +confiant-integrations.net +confidencetalks.com +configv2.unityads.unity3d.com +connatix.com +connectad.io +connextra.com +consciouscheese.com +consensad.com +consensu.org +contadores.miarroba.com +content.acc-hd.de +content.ad +content22.online.citi.com +contextweb.com +contrack.link +converge-digital.com +conversantmedia.com +conversionbet.com +conversionruler.com +convertingtraffic.com +convrse.media +cookies.cmpnet.com +coolkidsdistrict.com +cootlogix.com +copperstills.net +copycarpenter.com +copyrightaccesscontrols.com +coremetrics.com +cosmosjackson.com +count.rin.ru +count.west263.com +counted.com +counter.bloke.com +counter.cnw.cz +counter.cz +counter.dreamhost.com +counter.mirohost.net +counter.mojgorod.ru +counter.rambler.ru +counter.search.bg +counter.snackly.co +counting.kmindex.ru +coupling-media.de +cowbelltime.com +coxmt.com +cozyhillside.com +cpalead.com +cpays.com +cpmstar.com +cpu.samsungelectronics.com +cpx.to +cpxinteractive.com +cqcounter.com +crabbychin.com +craktraffic.com +crashchance.com +crashlytics.com +crashlyticsreports-pa.googleapis.com +cratecamera.com +crawlability.com +crazyegg.com +crazypopups.com +creatives.livejasmin.com +creatopy.net +crimsonmeadow.com +criteo.com +criteo.net +critictruck.com +crowdedmass.com +crowdgravity.com +crsspxl.com +crta.dailymail.co.uk +crtv.mate1.com +crwdcntrl.net +crypto-loot.org +crystalboulevard.com +cs.co +curbminers.com +curiousmetal.com +curtaincows.com +curveshore.com +cushiondrum.com +customad.cnn.com +customads.co +customerevents.netflix.com +customers.kameleoon.com +cutechin.com +cxense.com +cyberbounty.com +cyclinghere.com +d-collect.jennifersoft.com +d-collector.jennifersoft.com +d.adroll.com +d1f0tbk1v3e25u.cloudfront.net +d2cmedia.ca +d81mfvml8p5ml.cloudfront.net +dabiaozhi.com +dacdn.visualwebsiteoptimizer.com +dacdn.vwo.com +dakic-ia-300.com +damageddistance.com +damdoor.com +dapper.net +data.namesakeoscilloscopemarquis.com +datapickles.com +datenow.link +daybreaklights.com +dc-storm.com +de17a.com +dealdotcom.com +decenterads.com +decisivebase.com +decisivedrawer.com +decknetwork.net +deepintent.com +delicatecascade.com +deloo.de +demandbase.com +demdex.net +deployads.com +desertgates.com +desiredirt.com +detailedgovernment.com +detectdiscovery.com +dev.visualwebsiteoptimizer.com +dewdroplagoon.com +dianomi.com +dicecandies.com +didtheyreadit.com +digestiondrawer.com +digital-ads.s3.amazonaws.com +direct-events-collector.spot.im +direct-promo.pro +directaclick.com +directorym.com +discountclick.com +discreetfield.com +displayvertising.com +disqusads.com +dist.belnk.com +distillery.wistia.com +districtm.ca +districtm.io +dmp.mall.tv +dmtracker.com +dmtracking.alibaba.com +dmtracking2.alibaba.com +dnsdelegation.io +do-global.com +dockdigestion.com +dogcollarfavourbluff.com +domaining.in +domdex.com +dotmetrics.net +dotomi.com +doubleclick.com +doubleclick.de +doubleclick.net +doublepimp.com +doubleverify.com +dpbolvw.net +dpu.samsungelectronics.com +dq95d35.com +dragonbats.com +drumcash.com +dsp.colpirio.com +dsp.io +dstillery.com +dustyhammer.com +dyntrk.com +e-eu.customeriomail.com +e-m.fr +e-planning.net +e.customeriomail.com +e.kde.cz +e37364.dscd.akamaiedge.net +eadexchange.com +eas.almamedia.fi +easyhits4u.com +ebayadvertising.com +ebuzzing.com +echoacloud.com +ecircle-ag.com +ecleneue.com +eclick.vn +eclicks.deliveroo.com +eclkspbn.com +ecoupons.com +edaa.eu +edgexads.com +eighthlayer.net +eiv.baidu.com +elasticchange.com +elderlytown.com +elitedollars.com +em1.yoursantander.co.uk +email-link.adtidy.info +email-link.adtidy.net +email-link.adtidy.org +email-links.crowdfireapp.com +email-open.adtidy.net +email-open.adtidy.org +email-trk.ihg-businessedge.com +email.mg1.substack.com +emailer.stockbit.com +emaillinks.soundiiz.com +emebo.io +emerse.com +emetriq.de +emjcd.com +eml.blackduck.com +emltrk.com +emodoinc.com +emptyescort.com +emxdigital.com +energeticladybug.com +engage.tines.com +engage.windows.com +engagebdr.com +engageya.com +engine.espace.netavenir.com +engineertrick.com +enginenetwork.com +enormousearth.com +enquisite.com +ensighten.com +entercasino.com +entrecard.s3.amazonaws.com +enviousthread.com +epom.com +epp.bih.net.ba +eqads.com +eqy.link +erne.co +ero-advertising.com +essaygiants.com +esty.com +et.educationdynamics.com +et.nytimes.com +etahub.com +etargetnet.com +etracker.com +etracker.de +eu-adcenter.net +eule1.pmu.fr +eulerian.net +eurekster.com +euros4click.de +eusta.de +evadav.com +evadavdsp.pro +evencoating.com +eventexistence.com +events-eu.freshsuccess.com +events-us.freshsuccess.com +everestads.net +everesttech.net +evergage.com +eversales.space +evs.sgmt.loom.com +evyy.net +exampleshake.com +exchange-it.com +exchangead.com +exchangeclicksonline.com +exelate.com +exelator.com +exhibitsneeze.com +exit76.com +exitexchange.com +exitfuel.com +exoclick.com +exosrv.com +experianmarketingservices.digital +explorads.com +exponea.com +exponential.com +exportdialog.com +express-submit.de +extractobservation.com +extreme-dm.com +extremetracking.com +eyeota.net +eyeviewads.com +eyewonder.com +ezula.com +f.pie.org +f7ds.liberation.fr +fabric.io +fadedsnow.com +fairfeeling.com +fakedisguise.com +fallaciousfifth.com +fallingshoals.com +fam-ad.com +farethief.com +farmergoldfish.com +fast-redirecting.com +fastclick.com +fastclick.com.edgesuite.net +fastclick.net +fastly-insights.com +faultycanvas.com +fave.co +fc.webmasterpro.de +feedbackresearch.com +feedjit.com +feedmob.com +femalecook.com +figsprotein.com +figurehunter.net +fillthemap.com +fimserve.com +findcommerce.com +findyourcasino.com +fireads.online +fireads.org +fireworkadservices.com +fireworkanalytics.com +fireworks-advertising.com +firstlightera.com +fishingtoolsbox.com +fixedfold.com +fjordsand.com +flairadscpc.com +flakyfeast.com +flashtalking.com +flashtexting.com +fleshlightcash.com +flexbanner.com +flimsycircle.com +flimsythought.com +floodprincipal.com +flourishinginnovation.com +floweryflavor.com +flowgo.com +flurry.com +fontserif.com +foo.cosmocode.de +foresee.com +forex-affiliate.net +forkcdn.com +fourlevelsgame.com +fpctraffic.com +fpjs.io +fqtag.com +fraysystems.com +free-counter.co.uk +freebanner.com +freecounterstat.com +freedomgrail.com +freelogs.com +freepay.com +freestats.com +freestats.tv +freewebcounter.com +freewheel.com +freewheel.tv +freezingbuilding.com +frequentflesh.com +freshrelevance.com +fronttoad.com +frtyj.com +frtyk.com +fudgegenie.com +fullrestore.net +fullstory.com +functionalcrown.com +functionalfeather.com +funklicks.com +funnelytics.io +funtoyplanet.com +furryfork.com +fusionads.net +fusionquest.com +futuristicfifth.com +futuristicframe.com +fuzzybasketball.com +fwcdn1.com +fwcdn2.com +fxstyle.net +g2.gumgum.com +ga.clearbit.com +gadsbee.com +galaxien.com +game-advertising-online.com +gamesites100.net +gamesites200.com +gammamaximum.com +gaug.es +gavvia.com +gearwom.de +geo.digitalpoint.com +geobanner.adultfriendfinder.com +georiot.com +geovisite.com +getclicky.com +getintent.com +getmyads.com +getxmlisi.com +giddycoat.com +glasscoyote.com +glisteningsign.com +globalismedia.com +gloriousbeef.com +gmads.net +gml.email +go-clicks.de +go-link.network +go-mpulse.net +go-rank.de +go.clickwww.com +go.dhs.gov +go.eu.sparkpostmail1.com +go.first.org +go.icann.org +go.scmagazine.com +go.usa.gov +go.xlirdr.com +go2affise.com +godseedband.com +goingplatinum.com +goldstats.com +gondolagnome.com +goodcontentservice.top +google-analytics.com +googleadservices.com +googleanalytics.com +googlesyndication.com +googletagmanager.com +googletagservices.com +goqon.com +gostats.com +gothamads.com +gotoyahoo.com +gotraffic.net +gp.dejanews.com +grandfatherguitar.com +granlite.com +grapeshot.co.uk +greystripe.com +groovespacing.com +grouchybrothers.com +groundtruth.com +growthrx.in +gscontxt.net +guardeddirection.com +guardedschool.com +gumyfui.com +gunggo.com +h-bid.com +h-trck.com +h0.t.hubspotemail.net +haikusoap.com +halcyoncanyon.com +halocolor.com +haltingbadge.com +hammerhearing.com +hamsterspot.com +handsomehose.com +handyfireman.com +handyincrease.com +haplesshydrant.com +harrenmedia.com +harrenmedianetwork.com +hayweb.net +hb.afl.rakuten.co.jp +hb.vntsm.com +hbb.afl.rakuten.co.jp +hbopenbid.pubmatic.com +heap.com +heimi-lwx.com +hellobar.com +helpcollar.com +hentaicounter.com +herbalaffiliateprogram.com +hexcan.com +hexusads.fluent.ltd.uk +heyos.com +hf5rbejvpwds.com +hfc195b.com +hgads.com +highnoongear.com +hightrafficads.com +hilariouszinc.com +histats.com +historytrade.com +hit-parade.com +hit.ua +hit.webcentre.lycos.co.uk +hitbox.com +hitcounters.miarroba.com +hitlist.ru +hitlounge.com +hitometer.com +hits-i.iubenda.com +hits.europuls.eu +hits.informer.com +hits.puls.lv +hits.sh +hits.theguardian.com +hits4me.com +hitslink.com +hittail.com +hlok.qertewrt.com +hocgeese.com +hollowafterthought.com +homelycrown.com +homepageking.de +honorableland.com +hostedads.realitykings.com +hotjar.com +hotlog.ru +hotrank.com.tw +hoverowl.com +hs-analytics.net +hs-banner.com +hsadspixel.net +hsleadflows.net +hsn.uqhv.net +htlbid.com +httpool.com +hubspotlinks.com +hueads.com +hueadsortb.com +hueadsxml.com +hurricanedigitalmedia.com +hustlercoach.com +hydraconcept.com +hydramedia.com +hyperbanner.net +hypertracker.com +hyprmx.com +hystericalcloth.com +i-i.lt +i305175.net +ia.iinfo.cz +iad.anm.co.uk +iadnet.com +ibillboard.com +icptrack.com +icywinter.com +id5-sync.com +idealadvertising.net +idevaffiliate.com +idtargeting.com +ientrymail.com +iesnare.com +ifa.tube8live.com +ilbanner.com +ilead.itrack.it +illustriousoatmeal.com +image2.pubmatic.com +image3.pubmatic.com +image4.pubmatic.com +image6.pubmatic.com +imagecash.net +images-pw.secureserver.net +img.prohardver.hu +imgpromo.easyrencontre.com +immensehoney.com +imonomy.com +imp.i312864.net +impossibleexpansion.com +imprese.cz +impressionmedia.cz +impressionmonster.com +improvedigital.com +imrworldwide.com +inclk.com +incognitosearches.com +incoming-telemetry.thunderbird.net +incoming.telemetry.mozilla.org +indexexchange.com +indexstats.com +indexww.com +indieclick.com +industrybrains.com +inetlog.ru +infinite-ads.com +infinityads.com +infoevent.startappservice.com +infolinks.com +inmobi.com +inner-active.com +inner-active.mobi +innovid.com +inquisitiveinvention.com +insgly.net +insidepsych.net +insightexpress.com +insightexpressai.com +inskinad.com +inspectlet.com +install.365-stream.com +instantmadness.com +insticator.com +intelliads.com +intelligenceadx.com +interactive.forthnet.gr +intercom-clicks.com +intergi.com +internalcondition.com +internetfuel.com +interreklame.de +ioam.de +ip.ro +ip193.cn +iperceptions.com +ipredictive.com +ipstack.com +irchan.com +ireklama.cz +is-tracking-pixel-api-prod.appspot.com +islandwebhelp.com +itop.cz +its-that-easy.com +ivwbox.de +ivykiosk.com +iyfbodn.com +iyfnzgb.com +j93557g.com +jads.co +jamexport.com +jcount.com +jdoqocy.com +jewelrysprings.com +jinkads.de +joetec.net +joyoussurprise.com +js-agent.newrelic.com +js-api.otherlevels.com +js-tags.otherlevels.com +js.iterable.com +js.users.51.la +jsecoin.com +jsrdn.com +jubilantglimmer.com +juiceblocks.com +juicyads.com +juicyads.me +jumptap.com +jungroup.com +justicejudo.com +justpremium.com +justrelevant.com +justwowjars.com +k.iinfo.cz +kameleoon.eu +kanoodle.com +kargo.com +kernellife.com +kickoffo.site +kindads.com +kindlereunion.com +kissmetrics.com +kittentacos.com +kittycatking.com +klclick.com +klclick1.com +kliks.nl +knitstamp.com +knorex.com +knottyswing.com +komoona.com +kompasads.com +kontera.com +kost.tv +kpu.samsungelectronics.com +krxd.net +ktu.sv2.biz +kueezrtb.com +kvsadman.com +l1.britannica.com +lakesecure.com +lameletters.com +landkarts.com +larati.net +largebrass.com +laughcloth.com +launchbit.com +layer-ad.de +layer-ads.de +lazybumblebee.com +lbn.ru +lead02.com +leadboltads.net +leadclick.com +leadinfo.net +leadingedgecash.com +leadplace.fr +leadspace.com +leadzupc.com +leaplunchroom.com +leftliquid.com +lemmatechnologies.com +lemnisk.co +lever-analytics.com +lfeeder.com +lfstmedia.com +lgsmartad.com +li.alibris.com +li.azstarnet.com +li.dailycaller.com +li.gatehousemedia.com +li.gq.com +li.hearstmags.com +li.livingsocial.com +li.mw.drhinternet.net +li.onetravel.com +li.patheos.com +li.pmc.com +li.realtor.com +li.ziffimages.com +liadm.com +libraryfacts.com +lifeimpressions.net +liftdna.com +ligatus.com +ligatus.de +lightspeedcash.com +lightstep.medium.systems +lijit.com +limecodesign.com +link-booster.de +link.axios.com +link.beelivery.com +link.email.davidlloydclubs.co.uk +link.email.usmagazine.com +link.go.chase +link.sbstck.com +link.team.hyperoptic.com +link.theatlantic.com +link.uk.expediamail.com +linkbuddies.com +linkexchange.com +linkprice.com +linkrain.com +linkreferral.com +links-ranking.de +links.email.crunchbase.com +links.housekeep.com +links.prosservice.fr +links.zoopla.co.uk +linksoutside.com +linkstable.com +linkstorms.com +linkswaper.com +linksynergy.com +linktarget.com +linkvertise.com +liquidad.narrowcastmedia.com +litix.io +live.trmzum.com +liveadexchanger.com +liveintent.com +livelylaugh.com +liverail.com +livingsleet.com +lizardslaugh.com +lkqd.com +lnks.gd +loading321.com +loadsurprise.com +locked4.com +lockerdome.com +locolava.com +log.btopenworld.com +log.logrocket.io +log.pinterest.com +log.videocampaign.co +logger.snackly.co +logs.roku.com +logs.spilgames.com +logsss.com +logua.com +look.djfiln.com +look.ichlnk.com +look.opskln.com +look.ufinkln.com +loopme.com +loudlunch.com +lowest-prices.eu +lucidmedia.com +luckyorange.com +ludicrousarch.com +lyricshook.com +lytics.io +lzjl.com +m.trb.com +m2.ai +m32.media +m4n.nl +m6r.eu +mackeeperapp.mackeeper.com +madclient.uimserv.net +madcpms.com +madinad.com +madisonavenue.com +madvertise.de +magicadz.co +magicaljoin.com +magsrv.com +mail-ads.google.com +maltiverse.lt.acemlnc.com +manageadv.cblogs.eu +mantisadnetwork.com +mapcommand.com +marinsm.com +markedmeasure.com +marketing.888.com +marketing.desertcart.com +marketing.net.brillen.de +marketing.net.home24.de +marketing.net.occhiali24.it +marketing.nyi.net +marketing.osijek031.com +marketingcloudapis.com +marketingsolutions.yahoo.com +marketo.com +marlowpillow.sjv.io +marriedbelief.com +mas.sector.sk +matchcraft.com +matheranalytics.com +mathtag.com +matomo.activate.cz +matomo.crossiety.app +mautic.com +max.i12.de +maximiser.net +maxonclick.com +mbs.megaroticlive.com +mcdlks.com +mcs-va.tiktok.com +mcs-va.tiktokv.com +meadowlullaby.com +measlymiddle.com +measure.office.com +measuremap.com +meatydime.com +media-adrunner.mycomputer.com +media.funpic.de +media.net +media01.eu +media6degrees.com +mediaarea.eu +mediabridge.cc +mediafuse.com +mediageneral.com +mediaiqdigital.com +mediamath.com +mediamgr.ugo.com +mediaplazza.com +mediaplex.com +mediascale.de +mediaserver.bwinpartypartners.it +mediasmart.io +mediasquare.fr +mediatext.com +mediavine.com +mediavoice.com +mediax.angloinfo.com +mediaz.angloinfo.com +medleyads.com +medyanetads.com +meetrics.net +megacash.de +megastats.com +megawerbung.de +meltmilk.com +memorizeneck.com +mercuryace.com +merequartz.com +messagelists.com +metadsp.co.uk +metaffiliation.com +metajaws.com +metanetwork.com +methodcash.com +metrics-logger.spot.im +metrics.api.drift.com +metrics.articulate.com +metrics.cnn.com +metrics.foxnews.com +metrics.getrockerbox.com +metrics.gfycat.com +metrics.govexec.com +metrics.icloud.com +metrics.mzstatic.com +metrilo.com +mfadsrvr.com +mg2connext.com +mgid.com +microstatic.pl +microticker.com +milotree.com +mineinvoice.com +minewhat.com +minibilling.com +mintegral.com +mintfunnel.co +mittencattle.com +mix2ads.com +mixedreading.com +mixpanel.com +mkto-ab410147.com +mktoresp.com +ml314.com +mlm.de +mlsend.com +mltrk.io +mmismm.com +mmstat.com +mmtro.com +mntzrlt.net +moartraffic.com +moat.com +moatads.com +moatpixel.com +mobclix.com +mobfox.com +mobileanalytics.us-east-1.amazonaws.com +mobilefuse.com +modernpricing.com +mon-va.byteoversea.com +mon.byteoversea.com +monarchads.com +monetate.net +monetizer101.com +monkeyapes.com +monsterpops.com +mookie1.com +mopub.com +morefolks.com +motionlessmeeting.com +motionspots.com +mousestats.com +movad.net +movemeal.com +mparticle.com +mpstat.us +mr-rank.de +mrskincash.com +mstrlytcs.com +mtrcs.samba.tv +mtree.com +munchkin.marketo.net +mundanenail.com +mushroomgods.com +musiccounter.ru +muteknife.com +muwmedia.com +mxptint.net +myads.company +myads.net +myads.telkomsel.com +myaffiliateprogram.com +mybbc-analytics.files.bbci.co.uk +mybloglog.com +mybuys.com +mycounter.ua +mydas.mobi +mylead-tracking.tracknow.info +mylead.global +mylink-today.com +mypagerank.net +mypowermall.com +mysketchpad.com +mystat-in.net +mystat.pl +mytop-in.net +n69.com +naj.sk +nakedly.ai +nappyattack.com +nappyneck.com +nastydollars.com +nativeroll.tv +navegg.com +navigator.io +navrcholu.cz +ncaudienceexchange.com +ndparking.com +nebulacrescent.com +nedstatbasic.net +needyneedle.com +neighborlywatch.com +nend.net +neocounter.neoworx-blog-tools.net +nervoussummer.com +net-filter.com +netaffiliation.com +netagent.cz +netclickstats.com +netdirect.nl +netech.postaffiliatepro.com +netmera-web.com +netmera.com +netmng.com +netpool.netbookia.net +netshelter.net +neudesicmediagroup.com +newads.bangbros.com +newnet.qsrch.com +newnudecash.com +news-cdn.site +newsadsppush.com +newsbotnet.com +newt1.adultadworld.com +newt1.adultworld.com +nexac.com +nexage.com +ng3.ads.warnerbros.com +nitroclicks.com +nmtracking.netflix.com +noiselessplough.com +nondescriptcrowd.com +nondescriptnote.com +nondescriptstocking.com +novem.pl +nowaymail.com +npttech.com +nr-data.net +nr.mmcdn.com +nr.static.mmcdn.com +ns1p.net +ntv.io +ntvk1.ru +nullitics.com +nuseek.com +nzaza.com +o2.mouseflow.com +o333o.com +oafishobservation.com +oas.benchmark.fr +oas.repubblica.it +oas.roanoke.com +oas.toronto.com +oas.uniontrib.com +oascentral.chicagobusiness.com +oascentral.fortunecity.com +oascentral.register.com +objecthero.com +obscenesidewalk.com +oceancloudhosts.com +oclasrv.com +odbierz-bony.ovp.pl +oewa.at +offaces-butional.com +offer.fyber.com +offer.sponsorpay.com +offerforge.com +offermatica.com +offshoregeology.com +ogads-pa.googleapis.com +oglasi.posjetnica.com +ogury.com +ojrq.net +omg10.com +omnijay.com +omniture.com +omtrdc.net +onaudience.com +onclickads.net +onegg.site +onestat.com +onestatfree.com +online-metrix.net +online.miarroba.com +onlinecash.com +onlinecashmethod.com +onlinerewardcenter.com +onlinestarten.net +onscroll.com +onthe.io +opads.us +open.oneplus.net +openad.tf1.fr +openad.travelnow.com +openads.friendfinder.com +openads.org +openadsnetwork.com +openbid.pubmatic.com +openx.angelsgroup.org.uk +openx.cairo360.com +openx.net +openx.skinet.cz +openx.smcaen.fr +openx2.kytary.cz +operationchicken.com +opienetwork.com +opmnstr.com +oppuz.com +optimallimit.com +optimizely.com +optimost.com +optionsnomad.com +optmd.com +optmnstr.com +optmstr.com +optnmstr.com +optnx.com +orbsrv.com +orientedargument.com +orionember.com +ota.cartrawler.com +otto-images.developershed.com +ourdreamstaticpages.pages.dev +outbrain.com +overconfidentfood.com +overkick.com +overture.com +ow.pubmatic.com +owebmoney.ru +owlsr.us +owneriq.net +oxado.com +oxcash.com +oxen.hillcountrytexas.com +p-n.io +p7cloud.net +paa-reporting-advertising.amazon +page-checker.eu +pagead.l.google.com +pagefair.com +pagerank-ranking.de +pageranktop.com +painstakingpickle.com +paleleaf.com +panickypancake.com +panoramicplane.com +parachutehome.sjv.io +parchedsofa.com +parentpicture.com +parsely.com +parsimoniouspolice.com +partner-ads.com +partner.pelikan.cz +partnerad.l.google.com +partnerads.ysm.yahoo.com +partnercash.de +partnerhut.com +partnerlinks.io +partners.priceline.com +partplanes.com +passeura.com +paychat.fuse-cloud.com +paycounter.com +paypopup.com +pbnet.ru +pbterra.com +pc-tc.s3-eu-west-1.amazonaws.com +pcash.imlive.com +peep-auktion.de +peer39.com +pennyweb.com +pepperjamnetwork.com +percentmobile.com +perfectaudience.com +perfiliate.com +performancerevenue.com +performancerevenues.com +performancing.com +permutive.com +personagraph.com +pgl.example.com +pgl.example0101 +pgmediaserve.com +pgpartner.com +pheedo.com +phoenix-adrunner.mycomputer.com +piano.io +pimproll.com +ping.ublock.org +pipedream.wistia.com +pippio.com +piquantpigs.com +pix.spot.im +pixel.condenastdigital.com +pixel.keywee.co +pixel.sojern.com +pixel.watch +pixel.yabidos.com +placed.com +placeframe.com +placidactivity.com +plausible.avris.it +plausibleio.workers.dev +play4traffic.com +playhaven.com +pleasantpump.com +plista.com +plotrabbit.com +pltraffic8.com +pluckypocket.com +plugrush.com +pocketfaucet.com +poemprompt.com +pointlessprofit.com +pointroll.com +pokkt.com +polishedfolly.com +polo.feathr.co +popads.net +popcash.net +popmixradio.com +popmyads.com +popplantation.com +popub.com +popunder.ru +popunhot1.blogspot.com +popup.msn.com +popupmoney.com +popupnation.com +popuptraffic.com +porngraph.com +porntrack.com +possibleboats.com +possiblepencil.com +post.spmailtechno.com +postback.iqm.com +postrelease.com +ppc.adhere.marchex.com +pr-star.de +praddpro.de +prchecker.info +prebid.org +predictad.com +premium-offers.com +presetrabbits.com +prettyeasycafe.com +previousplayground.com +prf.hn +priceypies.com +pricklydebt.com +prideproms.com +primetime.net +privatecash.com +prmtracking.com +pro-market.net +probablepartner.com +processplantation.com +proext.com +profero.com +profitrumour.com +programattik.com +projectwonderful.com +promo.badoink.com +promobenef.com +promos.bwin.it +promos.fling.com +promote.pair.com +pronetadvertising.com +propellerads.com +propellerclick.com +proper.io +props.id +protectcrev.com +protectpool.com +protectsubrev.com +protestcopy.com +proton-tm.com +protraffic.com +provenpixel.com +prpops.com +prsitecheck.com +prufenzo.xyz +pstmrk.it +pub.chez.com +pub.club-internet.fr +pub.hardware.fr +pub.network +pub.realmedia.fr +pubdirecte.com +publicidad.elmundo.es +publicidees.com +publicsofa.com +pubmine.com +pubnative.net +puffyloss.com +puffypaste.com +puffypull.com +puffypurpose.com +pureclarity.net +pushance.com +pushengage.com +pushno.com +pushtrack.co +px.dynamicyield.com +px.gfycat.com +pxf.io +pxl-mailtracker.com +pxl.iqm.com +pymx5.com +q.azcentral.com +q1connect.com +qa-analytics.com +qctop.com +ql.tc +qnsr.com +qrlsx.com +quantcast.com +quantcount.com +quantserve.com +quantummetric.com +quarterserver.de +quickkoala.io +quietknowledge.com +quiltruler.com +quinst.com +quirkysugar.com +quisma.com +quizzicalzephyr.com +r.drinksdirect.net +r.logrocket.io +r.marketing.dubaisothebys.com +r.msn.com +r.scoota.co +r.sibmail.havasit.com +r1.arts-mail.com +r1.visualwebsiteoptimizer.com +r2.visualwebsiteoptimizer.com +r3.visualwebsiteoptimizer.com +rabbitrifle.com +rackforstorage.com +radar.cedexis.com +radiate.com +radiateprose.com +rads.realadmin.pl +railwayreason.com +rambunctiousflock.com +rampidads.com +randkuj.xyz +rankchamp.de +ranking-charts.de +ranking-hits.de +ranking-links.de +rankingscout.com +rankyou.com +rapidcounter.com +raresummer.com +rate.ru +ratings.lycos.com +rayjump.com +rcadserver.com +re-direct.pl +reachjunction.com +reactx.com +readingguilt.com +readymoon.com +realcastmedia.com +realclever.com +realclix.com +realmedia-a800.d4p.net +realsrv.com +realtechnetwork.com +realtracker.com +rebelhen.com +rebelswing.com +rec5.visualwebsiteoptimizer.com +recapture.io +receptivereaction.com +recoco.it +reconditerake.com +record.bonniergaming.com +record.mrwin.com +redirectingat.com +redirectvoluum.com +redrection.pro +redshell.io +reduxmedia.com +referralware.com +referrer.disqus.com +regularplants.com +reklam.rfsl.se +reklama.mironet.cz +reklamcsere.hu +reklamdsp.com +relmaxtop.com +rememberdiscussion.com +remox.com +report-1.appmetrica.webvisor.com +report-2.appmetrica.webvisor.com +report-partners.appmetrica.yandex.net +report.ap.yandex-net.ru +report.appmetrica.yandex.net +republika.onet.pl +resalag.com +rescuerhino.com +resonantbrush.com +resonate.com +responsiveads.com +restrainstorm.com +retargeter.com +rev.iq +revcatch.com +revcontent.com +reveal.clearbit.com +revenuedirect.com +revenuehits.com +revive.haskovo.net +revive.netriota.hu +revive.plays.bg +revprotect.com +revstats.com +rexadvert.xyz +reyden-x.com +rhombusads.com +rhythmone.com +richaudience.com +richmails.com +richstring.com +rightstats.com +ringplant.com +ringsrecord.com +ritzyrepresentative.com +rlcdn.com +rle.ru +rmads.msn.com +rmedia.boston.com +roaddynamics.com +roar.com +robotreplay.com +rockabox.co +rockagainst.com +rockstarwriter.com +rok.com.com +rollconnection.com +rose.ixbt.com +rotabanner.com +roxr.net +rpt-ads.vidaahub.com +rqtrk.eu +rs6.net +rsc-ads.vidaahub.com +rsc-mntz.vidaahub.com +rta.dailymail.co.uk +rtb.gumgum.com +rtbadzesto.com +rtbflairads.com +rtbplatform.net +rtbpop.com +rtbpopd.com +rtmark.net +rtxplatform.com +ru4.com +rubiconproject.com +rum-http-intake.logs.datadoghq.com +rum-http-intake.logs.datadoghq.eu +runads.com +rundsp.com +ruralrobin.com +s.adroll.com +s.dmmew.com +s20dh7e9dh.com +s2d6.com +sabio.us +sadloaf.com +safeoffers.pro +sail-horizon.com +samplesamba.com +samsungacr.com +samsungads.com +sanalytics.disneyplus.com +sanity-dataplane.rudderstack.com +savoryorange.com +sbird.xyz +sbx.pagesjaunes.fr +sc-analytics.appspot.com +scambiobanner.aruba.it +scanscout.com +scarcesign.com +scaredsnakes.com +scaredsong.com +scarfsmash.com +scatteredheat.com +scintillatingscissors.com +scintillatingsilver.com +scissorsstatement.com +scopelight.com +scorecardresearch.com +scratch2cash.com +screechingfurniture.com +screechingstocking.com +screechingstove.com +scrubswim.com +seadform.net +searchmarketing.com +searchramp.com +secre.jp +secretspiders.com +secure.webconnect.net +securedopen-bp.com +securemetrics.apple.com +securemetrics.apple.com.cn +sedoparking.com +sedotracker.com +segment-cdn.producthunt.com +selectivesummer.com +semasio.net +sendmepixel.com +seraphichorizon.com +serendipityecho.com +serv0.com +servclick1move.com +serve.tercept.com +servedby-buysellads.com +servedbyadbutler.com +servedbyopenx.com +servethis.com +services.hearstmags.com +sessioncam.com +sexcounter.com +sexlist.com +sextracker.com +shadowmade.com +shakegoldfish.com +shareasale.com +sharethrough.com +shd247.click +sher.index.hu +shesubscriptions.com +shinystat.com +shinystat.it +shiveringspot.com +shiverscissors.com +shockinggrass.com +shoppingads.com +showads.pubmatic.com +shredform.com +shrillspoon.com +shxtrk.com +sicksmash.com +sidebar.angelfire.com +signalayer.com +signalszone.com +sillyscrew.com +silvermob.com +simpleanalytics.io +simplesafari.com +simpli.fi +simulateswing.com +sincerebuffalo.com +sinoa.com +sitedataprocessing.com +siteimproveanalytics.com +siteimproveanalytics.io +siteintercept.qualtrics.com +sitemeter.com +sixscissors.com +sixsigmatraffic.com +sizmek.com +skimresources.com +skisofa.com +skroutza.skroutz.gr +skylink.vn +slimesupplies.net +slopeaota.com +smaato.com +smart-data-systems.com +smart-traffik.com +smart-traffik.io +smart4ads.com +smartadserver.com +smartclip.net +smartlook.com +smartstream.tv +smartyads.com +smashquartz.com +smashsurprise.com +smetrics.10daily.com.au +smetrics.bestbuy.com +smetrics.ctv.ca +smetrics.fedex.com +smetrics.foxnews.com +smetrics.walgreens.com +smetrics.washingtonpost.com +smilewanted.com +smilingcattle.com +smoggysnakes.com +smrtb.com +snapads.com +snazzypoodle.com +snoobi.com +socialspark.com +softclick.com.br +soggysponge.com +soicos.com +sombersea.com +sombersquirrel.com +sombersurprise.com +somniture.stuff.co.nz +somoaudience.com +sonobi.com +sortable.com +sourcepoint.vice.com +sovrn.com +sp-a-q-f.ib-game.jp +spacash.com +spaceleadster.com +sparklingshelf.com +sparkstudios.com +speakol.com +specificmedia.co.uk +specificpop.com +speedomizer.com +speedshiftmedia.com +spellingthoughts.com +spezialreporte.de +spiffymachine.com +spinbox.techtracker.com +spinbox.versiontracker.com +spinnaker-js.com +spirebaboon.com +sponsorads.de +sponsorpro.de +spookysleet.com +spotlessstamp.com +spotscenered.info +spotx.tv +spotxchange.com +springbot.com +springserve.com +sprysummit.com +spulse.net +spylog.com +spywarelabs.com +spywords.com +srvmath.com +srvtrck.com +srwww1.com +sshowads.pubmatic.com +sskzlabs.com +st.dynamicyield.com +st.pubmatic.com +stack-sonar.com +stackadapt.com +stakingsmile.com +stalesummer.com +starffa.com +starkscale.com +starrynets.com +starsmarter.com +startapp.com +stat-track.com +stat.cliche.se +stat.dyna.ultraweb.hu +stat.pl +stat.webmedia.pl +stat.xiaomi.com +stat.zenon.net +stat24.com +stat24.meta.ua +statcounter.com +statdynamic.com +static-tracking.klaviyo.com +static.fmpub.net +static.itrack.it +static.kameleoon.com +staticads.btopenworld.com +statistik-gallup.net +statm.the-adult-company.com +stats.blogger.com +stats.hyperinzerce.cz +stats.merriam-webster.com +stats.mirrorfootball.co.uk +stats.nextgen-email.com +stats.olark.com +stats.pusher.com +stats.rdphv.net +stats.self.com +stats.stb-ottow.de +stats.townnews.com +stats.wordpress.com +stats.wp.com +stats2.self.com +stats4all.com +statserv.net +statsie.com +statxpress.com +steadfastsound.com +steadfastsystem.com +steelhouse.com +steelhousemedia.com +stickyadstv.com +stiffgame.com +stimulatingsneeze.com +stomachscience.com +stopstomach.com +storetail.io +storygize.net +strack.pubmatic.com +straightnest.com +stretchsquirrel.com +studycooking.com +stupendoussleet.com +stupendoussnow.com +subscribe.hearstmags.com +succeedscene.com +successbuffet.com +sugoicounter.com +sulkycook.com +summerobject.com +sumo.com +sumome.com +sunsetcampfires.com +superawesome.tv +superchichair.com +superclix.de +superficialsquare.com +supersonicads.com +superstats.com +supertop.ru +supertop100.com +supply.colossusssp.com +supportwaves.com +surfmusik-adserver.de +surveygizmobeacon.s3.amazonaws.com +sw88.espn.com +swan-swan-goose.com +swankysquare.com +swingslip.com +swordgoose.com +synonymoussticks.com +systemssummit.com +t.appsflyer.com +t.bawafx.com +t.carta.com +t.co +t.eloqua.com +t.email.superdrug.com +t.en25.com +t.firstpromoter.com +t.insigit.com +t.irtyd.com +t.leady.com +t.mmtrkr.com +t.news.browns-restaurants.co.uk +t.notif-colissimo-laposte.info +t.pie.org +t.podcast.co +t.pubmatic.com +t.salesmatemail.com +t.vacations.disneydestinations.com +t.visit.disneydestinations.com +t.visitorqueue.com +t.x.co +t1.rorystravelclub.co.uk +t1.rorytravelclub-news.co.uk +taboola.com +tag-demo.mention-me.com +tag.mention-me.com +tagcommander.com +tagger.opecloud.com +tags.tiqcdn.com +tagtoo.com +tailsweep.com +tailsweep.se +takethatad.com +tamgrt.com +tangibleteam.com +tangyamount.com +tapad.com +tapfiliate.com +tapinfluence.com +tapjoy.com +tappx.com +targad.de +target.microsoft.com +targeting.api.drift.com +targeting.nzme.arcpublishing.com +targeting.voxus.tv +targetingnow.com +targetnet.com +targetpoint.com +tatsumi-sys.jp +tawdryson.com +tcads.net +teads.tv +tealeaf.com +tealium.cbsnews.com +tealium.com +tealiumiq.com +tedioustooth.com +teenrevenue.com +telaria.com +telemetry.dropbox.com +telemetry.goodlifefitness.com +telemetry.malwarebytes.com +telemetry.v.dropbox.com +temelio.com +tend.io +tendertest.com +ter-jrnl-oc.vidaahub.com +terriblethumb.com +text-link-ads.com +textad.sexsearch.com +textads.biz +textlinks.com +tfag.de +the-ozone-project.com +theadex.com +theadhost.com +theadsparks.com +thebugs.ws +thecrazychili.com +themangotea.com +themoneytizer.com +therapistla.com +thewavebeats.com +thinkitten.com +thirdparty.bnc.lt +thirdrespect.com +thomastorch.com +throtle.io +thruport.com +thunderhead.com +tia.timeinc.net +ticketaunt.com +ticklesign.com +ticksel.com +tics.techdirt.com +tidaltv.com +tidysprite.com +tinybar.com +tinybluewhale.com +tinytendency.com +tiresomethunder.com +tkbo.com +tls.telemetry.swe.quicinc.com +tlvmedia.com +tm.br.de +tnkexchange.com +tns-counter.ru +toolforthought.com +top-casting-termine.de +top-site-list.com +top.list.ru +top.mail.ru +top100-images.rambler.ru +top100.mafia.ru +top123.ro +top20free.com +toplist.cz +toplist.pornhost.com +toplista.mw.hu +toplistcity.com +topsir.com +topsite.lv +topsites.com.br +topstats.com +totemcash.com +touchclarity.com +tour.brazzers.com +tr.api.fanbyte.com +track-eu.customer.io +track.adform.net +track.anchorfree.com +track.canva.com +track.contently.com +track.customer.io +track.effiliation.com +track.flexlinks.com +track.flexlinkspro.com +track.lettingaproperty.com +track.mailalert.io +track.mailerlite.com +track.miro.com +track.nationalgunrights.org +track.privacyatclearbit.com +track.pubmatic.com +track.segmetrics.io +track.smtpmessage.com +track.software-codes.com +track.spe.schoolmessenger.com +track.ultravpn.com +track.unear.net +track.vcdc.com +track.viewdeos.com +track1.viewdeos.com +trackalyzer.com +trackedlink.net +trackedweb.net +tracker.bannerflow.com +tracker.cdnbye.com +tracker.icerocket.com +tracker.metricswave.com +tracker.mmdlv.it +tracker.samplicio.us +tracking.epicgames.com +tracking.hyros.com +tracking.ibxlink.com +tracking.intentsify.io +tracking.intl.miui.com +tracking.jiffyworld.com +tracking.markethero.io +tracking.miui.com +tracking.netalerts.io +tracking.olx-st.com +tracking.orixa-media.com +tracking.shopstyle.com +tracking.thinkabt.com +tracking.wetter.at +tracking01.walmart.com +tracking101.com +tracking22.com +trackingsoft.com +trackmysales.com +tradeadexchange.com +tradedoubler.com +traffic-exchange.com +traffic.hyteck.de +trafficfactory.biz +trafficforce.com +trafficholder.com +traffichunt.com +trafficjunky.net +trafficleader.com +trafficrouter.io +trafficshop.com +trafficspaces.net +trafficstrategies.com +trafficswarm.com +trafficz.com +traffiq.com +trafic.ro +traktrafficflow.com +tranquilplume.com +travis.bosscasinos.com +trck.a8.net +trck.mtrgt.id +trcklion.com +treasuredata.com +trekdata.com +tremendoustime.com +tremorhub.com +trendcounter.com +trendmd.com +trialfire.com +tribalfusion.com +triplelift.com +triptease.io +trk.4ff.pro +trk.bc.shutterfly.com +trk.pinterest.com +trk.sayerfinancial.com +trk.techtarget.com +trk1.avdlink.net +trk42.net +trkn.us +trkoptimizer.com +trkpnt.ongage.net +trmit.com +truckstomatoes.com +truculentrate.com +truehits.net +truehits1.gits.net.th +truehits2.gits.net.th +trust.titanhq.com +trustx.org +tsyndicate.com +tsyndicate.net +tubemogul.com +tumbleicicle.com +turboadv.com +turn.com +twelvedawn.com +twittad.com +twyn.com +tynt.com +typicalteeth.com +tyroo.com +uarating.com +ucfunnel.com +udkcrj.com +udncoeln.com +uib.ff.avast.com +ukoffzeh.com +ultimateclixx.com +ultramercial.com +ultraoranges.com +unaccountablepie.com +unarmedindustry.com +unbecominglamp.com +understoodocean.com +undertone.com +unidentifiedanalytics.web.app +unloadyourself.com +unruly.co +unrulymedia.com +untd.com +unusualtitle.com +unwieldyhealth.com +unwieldyimpulse.com +upgradeyoga.com +upu.samsungelectronics.com +urbanlaurel.com +url9467.comms-2.zoopla.co.uk +urlcash.net +us.a1.yimg.com +userreplay.com +userreplay.net +users.maxcluster.net +utils.mediageneral.net +utl-1.com +uu.domainforlite.com +v1.cnzz.com +v1adserver.com +valerie.forbes.com +validclick.com +valuead.com +valueclick.com +valueclickmedia.com +valuecommerce.com +vanfireworks.com +vcommission.com +veille-referencement.com +velismedia.com +venetrigni.com +vengefulgrass.com +ventivmedia.com +venturead.com +vericlick.com +vertamedia.com +verticalmass.com +vervewireless.com +vgnp3trk.com +vibrantsundown.com +vid.pubmatic.com +vidcpm.com +video-stats.video.google.com +videoadex.com +videoadstech.org +videoegg.com +videostats.kakao.com +vidora.com +view4cash.de +viglink.com +vilenexus.com +virtualvincent.com +visiblemeasures.com +visistat.com +visitbox.de +visual-pagerank.fr +visualrevenue.com +vivads.net +vivtracking.com +vmmpxl.com +voicefive.com +volatilevessel.com +voluum.com +voluumtrk2.com +vpon.com +vrs.cz +vtracy.de +vungle.com +w55c.net +wa.and.co.uk +waardex.com +warmafterthought.com +washbanana.com +wdads.sx.atl.publicus.com +wdfl.co +web-stat.com +web.informer.com +web2.deja.com +webads.co.nz +webads.nl +webanalytics.zohodcm.com +webcash.nl +webcontentassessor.com +webcounter.cz +webcounter.goweb.de +webgains.com +weborama.com +weborama.fr +webpower.com +webreseau.com +webseoanalytics.com +webstat.channel4.com +webstat.com +webstat.net +webtrackerplus.com +webtraffic.se +webtraxx.de +webxcdn.com +welved.com +werbung.meteoxpress.com +wetrack.it +whaleads.com +wheredoyoucomefrom.ovh +whirlwealth.com +whiskyqueue.com +whisperingcascade.com +whisperingcrib.com +whisperingsummit.com +whoisonline.net +wholepagecache.com +wickedreports.com +widget.educationdynamics.com +widget.privy.com +wikia-ads.wikia.com +wikiquotations.com +win.iqm.com +window.nixnet.cz +wintricksbanner.googlepages.com +wirecomic.com +wirypaste.com +wisepops.com +witch-counter.de +wittypopcorn.com +wizaly.com +wl.spotify.com +wlmarketing.com +wondoads.de +woopra.com +worldrealize.com +worldwide-cash.net +worriednumber.com +wowfunnow.com +writerhubs.com +wt-eu02.net +wt.bankmillennium.pl +www-banner.chat.ru +www-google-analytics.l.google.com +www.dnps.com +www.kaplanindex.com +www.photo-ads.co.uk +www8.glam.com +wwwpromoter.com +x-traceur.com +x6.yakiuchi.com +xad.com +xapads.com +xchange.ro +xertive.com +xfreeservice.com +xg4ken.com +xiti.com +xplusone.com +xponsor.com +xpu.samsungelectronics.com +xq1.net +xtendmedia.com +xtracker.logimeter.com +xxxcounter.com +xxxmyself.com +y.ibsys.com +yab-adimages.s3.amazonaws.com +yadro.ru +yandexmetrica.com +yepads.com +yesads.com +yesadvertising.com +yieldads.com +yieldlab.net +yieldmanager.net +yieldmo.com +yieldoptimizer.com +yieldtraffic.com +yldbt.com +ymetrica1.com +yoads.net +yoggrt.com +youcandrawanything.com +youradexchange.com +ypu.samsungelectronics.com +zangocash.com +zanox-affiliate.de +zanox.com +zantracker.com +zarget.com +zdbb.net +zedo.com +zemanta.com +zencudo.co.uk +zenkreka.com +zenzuu.com +zephyrlabyrinth.com +zeus.developershed.com +zeusclicks.com +zeydoo.com +zion-telemetry.api.cnn.io +zippingcare.com +zlp6s.pw +zm232.com +zmedia.com +zonewedgeshaft.com +zpu.samsungelectronics.com +zqtk.net +zzhc.vnet.cn + diff --git a/browser/vendor/tld-0.13.2-psl.NOTICE b/browser/vendor/tld-0.13.2-psl.NOTICE new file mode 100644 index 000000000..9a83edb1f --- /dev/null +++ b/browser/vendor/tld-0.13.2-psl.NOTICE @@ -0,0 +1,15 @@ +The adaptive tracker reproduces the Public Suffix List snapshot bundled with +tld 0.13.2. The complete base list is supplied by the pinned psl 2.1.180 Rust +crate; src/scrapling/adaptive.rs carries the exact rule delta needed to restore +the frozen snapshot. + +Frozen source metadata: + + VERSION: 2026-03-06_02-20-37_UTC + COMMIT: a7621207f34b3a739a5cfb2c47a518b0b9054fe6 + SHA-256: abf32ce9987d505b89765d76f35760543851235508f1f426b5b259a2062b5f68 + +The Public Suffix List is licensed under the Mozilla Public License, v. 2.0: +https://mozilla.org/MPL/2.0/ + +Canonical source: https://publicsuffix.org/list/public_suffix_list.dat diff --git a/browser/vendor/xmloxide/Cargo.toml b/browser/vendor/xmloxide/Cargo.toml new file mode 100644 index 000000000..e7c0f6de1 --- /dev/null +++ b/browser/vendor/xmloxide/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "xmloxide" +version = "0.5.0" +edition = "2021" +rust-version = "1.81" +autobins = false +autobenches = false +license = "MIT" +description = "Repository-owned Scrapling compatibility fork of xmloxide" + +[features] +default = [] +ffi = [] +serde = ["dep:serde"] +async = ["dep:tokio"] + +[dependencies] +encoding_rs = "0.8" +serde = { version = "1", optional = true } +tokio = { version = "1", features = ["io-util"], optional = true } + +[lib] +path = "src/lib.rs" diff --git a/browser/vendor/xmloxide/LICENSE b/browser/vendor/xmloxide/LICENSE new file mode 100644 index 000000000..2d1bfd251 --- /dev/null +++ b/browser/vendor/xmloxide/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jon Wiggins + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/browser/vendor/xmloxide/README.md b/browser/vendor/xmloxide/README.md new file mode 100644 index 000000000..065e27abb --- /dev/null +++ b/browser/vendor/xmloxide/README.md @@ -0,0 +1,389 @@ +# xmloxide + +[![CI](https://github.com/jonwiggins/xmloxide/actions/workflows/ci.yml/badge.svg)](https://github.com/jonwiggins/xmloxide/actions/workflows/ci.yml) +[![crates.io](https://img.shields.io/crates/v/xmloxide.svg)](https://crates.io/crates/xmloxide) +[![docs.rs](https://docs.rs/xmloxide/badge.svg)](https://docs.rs/xmloxide) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![MSRV](https://img.shields.io/badge/MSRV-1.81-blue.svg)](https://www.rust-lang.org) + +A pure Rust reimplementation of [libxml2](https://gitlab.gnome.org/GNOME/libxml2) — the de facto standard XML/HTML parsing library in the open-source world. + +libxml2 became officially unmaintained in December 2025 with known security issues. xmloxide aims to be a memory-safe, high-performance replacement that passes the same conformance test suites. + +## Features + +- **Memory-safe** — arena-based tree with zero `unsafe` in the public API +- **Conformant** — 100% pass rate on the W3C XML Conformance Test Suite (1727/1727 applicable tests) +- **Error recovery** — parse malformed XML and still produce a usable tree, just like libxml2 +- **Multiple parsing APIs** — DOM tree, SAX2 streaming, XmlReader pull, push/incremental +- **HTML parser** — error-tolerant HTML 4.01 parsing with auto-closing and void elements +- **WHATWG HTML5 parser** — full [HTML Living Standard](https://html.spec.whatwg.org/) tokenizer and tree builder (8810/8810 html5lib-tests passing) +- **HTML5 streaming** — SAX-like callback API for HTML5 (`html5::sax`) that wraps the tokenizer without building a DOM tree +- **CSS selectors** — query elements with familiar CSS syntax (`css::select`) including combinators, pseudo-classes, and fast `#id` lookup +- **XPath 1.0+** — full expression parser and evaluator with all XPath 1.0 core functions plus key XPath 2.0 functions (`matches()`, `replace()`, `tokenize()`, `upper-case()`, `lower-case()`, `abs()`, `min()`, `max()`, and more) +- **Validation** — DTD, RelaxNG, XML Schema (XSD), and ISO Schematron (ISO/IEC 19757-3) validation +- **Serde integration** — optional `serde` feature for XML (de)serialization to/from Rust types +- **Async parsing** — optional `async` feature for parsing from `tokio::io::AsyncRead` sources +- **Canonical XML** — C14N 1.0 and Exclusive C14N serialization +- **XInclude** — document inclusion processing +- **XML Catalogs** — OASIS XML Catalogs for URI resolution +- **`xmllint` CLI** — command-line tool for parsing, validating, and querying XML +- **Zero-copy where possible** — string interning for fast comparisons +- **No global state** — each `Document` is self-contained and `Send + Sync` +- **C/C++ FFI** — full C API with header file (`include/xmloxide.h`) for embedding in C/C++ projects +- **Minimal dependencies** — only `encoding_rs` (library has zero other deps; `clap` is CLI-only) + +## Quick Start + +```rust +use xmloxide::Document; + +let doc = Document::parse_str("<root><child>Hello</child></root>").unwrap(); +let root = doc.root_element().unwrap(); +assert_eq!(doc.node_name(root), Some("root")); +assert_eq!(doc.text_content(root), "Hello"); +``` + +### Serialization + +```rust +use xmloxide::Document; +use xmloxide::serial::serialize; + +let doc = Document::parse_str("<root><child>Hello</child></root>").unwrap(); +let xml = serialize(&doc); +assert_eq!(xml, "<root><child>Hello</child></root>"); +``` + +### XPath Queries + +```rust +use xmloxide::Document; +use xmloxide::xpath::{evaluate, XPathValue}; + +let doc = Document::parse_str("<library><book><title>Rust</title></book></library>").unwrap(); +let root = doc.root_element().unwrap(); +let result = evaluate(&doc, root, "count(book)").unwrap(); +assert_eq!(result.to_number(), 1.0); +``` + +### SAX2 Streaming + +```rust +use xmloxide::sax::{parse_sax, SaxHandler, DefaultHandler}; +use xmloxide::parser::ParseOptions; + +struct MyHandler; +impl SaxHandler for MyHandler { + fn start_element(&mut self, name: &str, _: Option<&str>, _: Option<&str>, + _: &[(String, String, Option<String>, Option<String>)]) { + println!("Element: {name}"); + } +} + +parse_sax("<root><child/></root>", &ParseOptions::default(), &mut MyHandler).unwrap(); +``` + +### HTML Parsing + +```rust +use xmloxide::html::parse_html; + +let doc = parse_html("<p>Hello <br> World").unwrap(); +let root = doc.root_element().unwrap(); +assert_eq!(doc.node_name(root), Some("html")); +``` + +### CSS Selectors + +```rust +use xmloxide::css::select; +use xmloxide::Document; + +let doc = Document::parse_str(r#"<div><p class="intro">Hello</p><p>World</p></div>"#).unwrap(); +let root = doc.root_element().unwrap(); +let intros = select(&doc, root, "p.intro").unwrap(); +assert_eq!(intros.len(), 1); +assert_eq!(doc.text_content(intros[0]), "Hello"); +``` + +### HTML5 Parsing (WHATWG) + +```rust +use xmloxide::html5::parse_html5; + +let doc = parse_html5("<p>Hello <b>world</b>").unwrap(); +let root = doc.root_element().unwrap(); +assert_eq!(doc.node_name(root), Some("html")); +``` + +Fragment parsing (the algorithm behind `innerHTML`) is also supported: + +```rust +use xmloxide::html5::{parse_html5_with_options, Html5ParseOptions}; + +let opts = Html5ParseOptions { + scripting: false, + fragment_context: Some("body".to_string()), +}; +let doc = parse_html5_with_options("<p>fragment</p>", &opts).unwrap(); +``` + +### HTML5 Streaming (SAX-like) + +```rust +use xmloxide::html5::sax::{Html5SaxHandler, parse_html5_sax}; + +struct LinkExtractor { hrefs: Vec<String> } +impl Html5SaxHandler for LinkExtractor { + fn start_element(&mut self, name: &str, attrs: &[(String, String)], _sc: bool) { + if name == "a" { + if let Some((_, href)) = attrs.iter().find(|(n, _)| n == "href") { + self.hrefs.push(href.clone()); + } + } + } +} + +let mut handler = LinkExtractor { hrefs: Vec::new() }; +parse_html5_sax(r#"<a href="/page">Link</a>"#, &mut handler); +assert_eq!(handler.hrefs, vec!["/page"]); +``` + +### Error Recovery + +```rust +use xmloxide::parser::{parse_str_with_options, ParseOptions}; + +let opts = ParseOptions::default().recover(true); +let doc = parse_str_with_options("<root><unclosed>", &opts).unwrap(); +for diag in &doc.diagnostics { + eprintln!("{}", diag); +} +``` + +## CLI Tool + +```sh +# Parse and pretty-print +xmllint --format document.xml + +# Validate against a schema +xmllint --schema schema.xsd document.xml +xmllint --relaxng schema.rng document.xml +xmllint --schematron schema.sch document.xml +xmllint --dtdvalid schema.dtd document.xml + +# XPath query +xmllint --xpath "//title" document.xml + +# Canonical XML +xmllint --c14n document.xml + +# Parse HTML +xmllint --html page.html +``` + +## Module Overview + +| Module | Description | +|--------|-------------| +| `tree` | Arena-based DOM tree (`Document`, `NodeId`, `NodeKind`) | +| `parser` | XML 1.0 recursive descent parser with error recovery | +| `parser::push` | Push/incremental parser for chunked input | +| `html` | Error-tolerant HTML 4.01 parser | +| `html5` | WHATWG HTML Living Standard parser (tokenizer + tree builder) | +| `html5::sax` | Streaming SAX-like API for HTML5 (no DOM tree built) | +| `css` | CSS selector engine for querying document trees | +| `sax` | SAX2 streaming event-driven parser | +| `reader` | XmlReader pull-based parsing API | +| `serial` | XML, HTML, and HTML5 serializers, plus Canonical XML (C14N) | +| `xpath` | XPath 1.0+ expression parser and evaluator | +| `validation::dtd` | DTD parsing and validation | +| `validation::relaxng` | RelaxNG schema validation | +| `validation::xsd` | XML Schema (XSD) validation | +| `validation::schematron` | ISO Schematron rule-based validation | +| `serde_xml` | Serde XML (de)serialization (optional `serde` feature) | +| `async_xml` | Async parsing via `tokio::io::AsyncRead` (optional `async` feature) | +| `xinclude` | XInclude 1.0 document inclusion | +| `catalog` | OASIS XML Catalogs for URI resolution | +| `encoding` | Character encoding detection and transcoding | +| `ffi` | C/C++ FFI bindings (`include/xmloxide.h`) | + +## Performance + +Parsing throughput is competitive with libxml2 — within 3-4% on most documents, and **12% faster** on SVG. Serialization is **1.5-2.4x faster** thanks to the arena-based tree design. XPath is **1.1-2.7x faster** across all benchmarks. + +**Parsing:** + +| Document | Size | xmloxide | libxml2 | Result | +|----------|------|----------|---------|--------| +| Atom feed | 4.9 KB | 26.7 µs (176 MiB/s) | 25.5 µs (184 MiB/s) | ~4% slower | +| SVG drawing | 6.3 KB | 58.5 µs (103 MiB/s) | 65.6 µs (92 MiB/s) | **12% faster** | +| Maven POM | 11.5 KB | 76.9 µs (142 MiB/s) | 74.2 µs (148 MiB/s) | ~4% slower | +| XHTML page | 10.2 KB | 69.5 µs (139 MiB/s) | 61.5 µs (157 MiB/s) | ~13% slower | +| Large (374 KB) | 374 KB | 2.15 ms (169 MiB/s) | 2.08 ms (175 MiB/s) | ~3% slower | + +**Serialization:** + +| Document | Size | xmloxide | libxml2 | Result | +|----------|------|----------|---------|--------| +| Atom feed | 4.9 KB | 11.3 µs | 17.5 µs | **1.5x faster** | +| Maven POM | 11.5 KB | 20.1 µs | 47.5 µs | **2.4x faster** | +| Large (374 KB) | 374 KB | 614 µs | 1397 µs | **2.3x faster** | + +**XPath:** + +| Expression | xmloxide | libxml2 | Result | +|------------|----------|---------|--------| +| Simple path (`//entry/title`) | 1.51 µs | 1.63 µs | **8% faster** | +| Attribute predicate (`//book[@id]`) | 5.91 µs | 15.99 µs | **2.7x faster** | +| `count()` function | 1.09 µs | 1.67 µs | **1.5x faster** | +| `string()` function | 1.32 µs | 1.77 µs | **1.3x faster** | + +Key optimizations: arena-based tree for fast serialization, byte-level pre-checks for character validation, bulk text scanning, ASCII fast paths for name parsing, zero-copy element name splitting, inline entity resolution, XPath `//` step fusion with fused axis expansion, inlined tree accessors, and name-test fast paths for child/descendant axes. + +```sh +# Run benchmarks (requires libxml2 system library) +cargo bench --features bench-libxml2 --bench comparison_bench +``` + +## Testing + +- **1078 unit tests** across all modules +- **138 FFI tests** covering the full C API surface (including SAX, Schematron, and CSS) +- **libxml2 compatibility suite** — 119/119 tests passing (100%) covering XML parsing, namespaces, error detection, and HTML parsing +- **W3C XML Conformance Test Suite** — 1727/1727 applicable tests passing (100%) +- **html5lib-tests** — 7032/7032 tokenizer tests + 1778/1778 tree construction tests (100%) +- **Integration tests** covering real-world XML/HTML documents, edge cases, and error recovery + +```sh +cargo test --all-features +``` + +## C/C++ FFI + +xmloxide provides a C-compatible API for embedding in C/C++ projects (like Chromium, game engines, or any codebase that currently uses libxml2). + +```sh +# Build shared + static libraries (uses the included Makefile) +make + +# Or build individually: +make shared # .so / .dylib / .dll +make static # .a / .lib + +# Build and run the C example +make example +``` + +```c +#include "xmloxide.h" + +xmloxide_document *doc = xmloxide_parse_str("<root>Hello</root>"); +uint32_t root = xmloxide_doc_root_element(doc); +char *name = xmloxide_node_name(doc, root); // "root" +char *text = xmloxide_node_text_content(doc, root); // "Hello" + +xmloxide_free_string(name); +xmloxide_free_string(text); +xmloxide_free_doc(doc); +``` + +The full API — including tree navigation and mutation, XPath evaluation, serialization (plain and pretty-printed), HTML/HTML5 parsing, DTD/RelaxNG/XSD/Schematron validation, C14N, SAX streaming, XmlReader, push parser, and XML Catalogs — is declared in [`include/xmloxide.h`](include/xmloxide.h). + +## Migrating from libxml2 + +| libxml2 | xmloxide (Rust) | xmloxide (C FFI) | +|---------|----------------|------------------| +| `xmlReadMemory` | `Document::parse_str` | `xmloxide_parse_str` | +| `xmlReadFile` | `Document::parse_file` | `xmloxide_parse_file` | +| `xmlParseDoc` | `Document::parse_bytes` | `xmloxide_parse_bytes` | +| `htmlReadMemory` | `html::parse_html` | `xmloxide_parse_html` | +| (HTML5 parsing) | `html5::parse_html5` | — | +| (HTML5 fragment / innerHTML) | `html5::parse_html5_with_options` | — | +| (HTML5 streaming) | `html5::sax::parse_html5_sax` | — | +| (CSS selectors / `querySelector`) | `css::select` | — | +| `xmlFreeDoc` | (drop `Document`) | `xmloxide_free_doc` | +| `xmlDocGetRootElement` | `doc.root_element()` | `xmloxide_doc_root_element` | +| `xmlNodeGetContent` | `doc.text_content(id)` | `xmloxide_node_text_content` | +| `xmlNodeSetContent` | `doc.set_text_content(id, s)` | `xmloxide_set_text_content` | +| `xmlGetProp` | `doc.attribute(id, name)` | `xmloxide_node_attribute` | +| `xmlSetProp` | `doc.set_attribute(...)` | `xmloxide_set_attribute` | +| `xmlNewNode` | `doc.create_node(...)` | `xmloxide_create_element` | +| `xmlNewText` | `doc.create_node(Text{..})` | `xmloxide_create_text` | +| `xmlAddChild` | `doc.append_child(p, c)` | `xmloxide_append_child` | +| `xmlAddPrevSibling` | `doc.insert_before(ref, c)` | `xmloxide_insert_before` | +| `xmlUnlinkNode` | `doc.remove_node(id)` | `xmloxide_remove_node` | +| `xmlCopyNode` | `doc.clone_node(id, deep)` | `xmloxide_clone_node` | +| `xmlGetID` | `doc.element_by_id(s)` | `xmloxide_element_by_id` | +| `xmlDocDumpMemory` | `serial::serialize(&doc)` | `xmloxide_serialize` | +| `xmlDocDumpFormatMemory` | `serial::serialize_with_options` | `xmloxide_serialize_pretty` | +| `htmlDocDumpMemory` | `serial::html::serialize_html` | `xmloxide_serialize_html` | +| `xmlC14NDocDumpMemory` | `serial::c14n::canonicalize` | `xmloxide_canonicalize` | +| `xmlXPathEvalExpression` | `xpath::evaluate` | `xmloxide_xpath_eval` | +| `xmlValidateDtd` | `validation::dtd::validate` | `xmloxide_validate_dtd` | +| `xmlRelaxNGValidateDoc` | `validation::relaxng::validate` | `xmloxide_validate_relaxng` | +| `xmlSchemaValidateDoc` | `validation::xsd::validate_xsd` | `xmloxide_validate_xsd` | +| (Schematron validation) | `validation::schematron::validate_schematron` | `xmloxide_validate_schematron` | +| `xmlXIncludeProcess` | `xinclude::process_xincludes` | `xmloxide_process_xincludes` | +| `xmlLoadCatalog` | `Catalog::parse` | `xmloxide_parse_catalog` | +| `xmlSAX2...` callbacks | `sax::SaxHandler` trait | `xmloxide_sax_parse` | +| `xmlTextReaderRead` | `reader::XmlReader` | `xmloxide_reader_read` | +| `xmlCreatePushParserCtxt` | `parser::PushParser` | `xmloxide_push_parser_new` | +| `xmlParseChunk` | `PushParser::push` | `xmloxide_push_parser_push` | + +**Thread safety:** Unlike libxml2, xmloxide has no global state. Each `Document` is self-contained and `Send + Sync`. The FFI layer uses thread-local storage for the last error message — each thread has its own error state. No initialization or cleanup functions are needed. + +## Fuzzing + +xmloxide includes fuzz targets for security testing: + +```sh +# Install cargo-fuzz (requires nightly) +cargo install cargo-fuzz + +# Run a fuzz target +cargo +nightly fuzz run fuzz_xml_parse +cargo +nightly fuzz run fuzz_html_parse +cargo +nightly fuzz run fuzz_html5_parse +cargo +nightly fuzz run fuzz_html5_fragment +cargo +nightly fuzz run fuzz_xpath +cargo +nightly fuzz run fuzz_roundtrip +cargo +nightly fuzz run fuzz_sax +cargo +nightly fuzz run fuzz_reader +cargo +nightly fuzz run fuzz_push +cargo +nightly fuzz run fuzz_validation +cargo +nightly fuzz run fuzz_schematron +``` + +## Building + +```sh +cargo build +cargo test +cargo clippy --all-targets --all-features -- -D warnings +cargo bench +``` + +Minimum supported Rust version: **1.81** + +## Limitations + +- **No XML 1.1** — xmloxide implements XML 1.0 (Fifth Edition) only. XML 1.1 is rarely used and not planned. +- **No XSLT** — XSLT is a separate specification (libxslt) and is out of scope. +- **HTML parsers** — both an HTML 4.01 parser (matching libxml2's behavior) and a full WHATWG HTML5 parser are provided. The HTML5 parser passes 100% of html5lib-tests. +- **Push parser buffers internally** — the push/incremental parser API (`PushParser`) currently buffers all pushed data and performs the full parse on `finish()`, rather than truly streaming like libxml2's `xmlParseChunk`. SAX streaming (`parse_sax` for XML, `html5::sax::parse_html5_sax` for HTML5) is available as an alternative for memory-constrained large-document processing. +- **XPath `namespace::` axis** — the `namespace::` axis returns the element node when in-scope namespaces match (rather than materializing separate namespace nodes), following the same pattern as the attribute axis. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for version history. + +## License + +MIT diff --git a/browser/vendor/xmloxide/benches/comparison_bench.rs b/browser/vendor/xmloxide/benches/comparison_bench.rs new file mode 100644 index 000000000..7f254d05b --- /dev/null +++ b/browser/vendor/xmloxide/benches/comparison_bench.rs @@ -0,0 +1,224 @@ +//! Head-to-head benchmark comparing xmloxide against libxml2. +//! +//! Run with: `cargo bench --features bench-libxml2 --bench comparison_bench` +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::fmt::Write; + +use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; + +use xmloxide::parser::ParseOptions; +use xmloxide::sax::{parse_sax, SaxHandler}; +use xmloxide::serial::serialize; +use xmloxide::xpath::evaluate; +use xmloxide::Document; + +#[cfg(feature = "bench-libxml2")] +use libxml::parser::Parser as LibxmlParser; +#[cfg(feature = "bench-libxml2")] +use libxml::xpath::Context as LibxmlXPathContext; + +// --------------------------------------------------------------------------- +// Fixture loading +// --------------------------------------------------------------------------- + +const ATOM_FEED: &str = include_str!("fixtures/atom_feed.xml"); +const SVG_DRAWING: &str = include_str!("fixtures/svg_drawing.xml"); +const MAVEN_POM: &str = include_str!("fixtures/maven_pom.xml"); +const XHTML_PAGE: &str = include_str!("fixtures/xhtml_page.xml"); + +/// Generates a large XML document at runtime (~100KB). +fn make_large_xml() -> String { + let mut xml = String::from("<?xml version=\"1.0\"?>\n<database>\n"); + for i in 0..2000 { + let _ = writeln!( + xml, + " <record id=\"{i}\" status=\"active\" priority=\"{}\">\ + <name>Record {i}</name>\ + <value>{}</value>\ + <description>This is the description for record number {i} in our database.</description>\ + </record>", + i % 5, + i * 42 + ); + } + xml.push_str("</database>\n"); + xml +} + +// --------------------------------------------------------------------------- +// Parse throughput benchmarks +// --------------------------------------------------------------------------- + +fn bench_parse_throughput(c: &mut Criterion) { + let large_xml = make_large_xml(); + + let fixtures: Vec<(&str, &str)> = vec![ + ("atom_feed", ATOM_FEED), + ("svg_drawing", SVG_DRAWING), + ("maven_pom", MAVEN_POM), + ("xhtml_page", XHTML_PAGE), + ("large_generated", &large_xml), + ]; + + let mut group = c.benchmark_group("parse_throughput"); + + for (name, xml) in &fixtures { + group.throughput(criterion::Throughput::Bytes(xml.len() as u64)); + + group.bench_with_input(BenchmarkId::new("xmloxide", name), xml, |b, xml| { + b.iter(|| Document::parse_str(black_box(xml))); + }); + + #[cfg(feature = "bench-libxml2")] + group.bench_with_input(BenchmarkId::new("libxml2", name), xml, |b, xml| { + let parser = LibxmlParser::default(); + b.iter(|| parser.parse_string(black_box(xml))); + }); + } + + group.finish(); +} + +// --------------------------------------------------------------------------- +// Serialize throughput benchmarks +// --------------------------------------------------------------------------- + +fn bench_serialize_throughput(c: &mut Criterion) { + let large_xml = make_large_xml(); + + let fixtures: Vec<(&str, &str)> = vec![ + ("atom_feed", ATOM_FEED), + ("maven_pom", MAVEN_POM), + ("large_generated", &large_xml), + ]; + + let mut group = c.benchmark_group("serialize_throughput"); + + for (name, xml) in &fixtures { + // xmloxide serialize + let doc = Document::parse_str(xml).expect("xmloxide parse failed"); + group.bench_with_input(BenchmarkId::new("xmloxide", name), &doc, |b, doc| { + b.iter(|| serialize(black_box(doc))); + }); + + // libxml2 serialize + #[cfg(feature = "bench-libxml2")] + { + let parser = LibxmlParser::default(); + let libxml_doc = parser.parse_string(xml).expect("libxml2 parse failed"); + group.bench_function(BenchmarkId::new("libxml2", name), |b| { + b.iter(|| { + let _ = black_box(libxml_doc.to_string()); + }); + }); + } + } + + group.finish(); +} + +// --------------------------------------------------------------------------- +// XPath benchmarks +// --------------------------------------------------------------------------- + +fn bench_xpath(c: &mut Criterion) { + let expressions: Vec<(&str, &str, &str)> = vec![ + ("simple_path", ATOM_FEED, "//entry/title"), + ("attribute_pred", MAVEN_POM, "//dependency[scope='test']"), + ("count_func", ATOM_FEED, "count(//entry)"), + ("string_func", ATOM_FEED, "string(//feed/title)"), + ]; + + let mut group = c.benchmark_group("xpath"); + + for (name, xml, expr) in &expressions { + // xmloxide xpath + let doc = Document::parse_str(xml).expect("xmloxide parse failed"); + let root = doc.root(); + group.bench_function(BenchmarkId::new("xmloxide", name), |b| { + b.iter(|| evaluate(black_box(&doc), root, black_box(expr))); + }); + + // libxml2 xpath + #[cfg(feature = "bench-libxml2")] + { + let parser = LibxmlParser::default(); + let libxml_doc = parser.parse_string(xml).expect("libxml2 parse failed"); + let ctx = LibxmlXPathContext::new(&libxml_doc).expect("xpath context failed"); + group.bench_function(BenchmarkId::new("libxml2", name), |b| { + b.iter(|| ctx.evaluate(black_box(expr))); + }); + } + } + + group.finish(); +} + +// --------------------------------------------------------------------------- +// SAX streaming benchmark (xmloxide only — libxml crate has no SAX API) +// --------------------------------------------------------------------------- + +struct CountingHandler { + elements: u64, + characters: u64, +} + +impl SaxHandler for CountingHandler { + fn start_element( + &mut self, + _local_name: &str, + _prefix: Option<&str>, + _namespace: Option<&str>, + _attributes: &[(String, String, Option<String>, Option<String>)], + ) { + self.elements += 1; + } + + fn characters(&mut self, _content: &str) { + self.characters += 1; + } +} + +fn bench_sax_streaming(c: &mut Criterion) { + let large_xml = make_large_xml(); + + let fixtures: Vec<(&str, &str)> = vec![ + ("atom_feed", ATOM_FEED), + ("maven_pom", MAVEN_POM), + ("large_generated", &large_xml), + ]; + + let mut group = c.benchmark_group("sax_streaming"); + let options = ParseOptions::default(); + + for (name, xml) in &fixtures { + group.throughput(criterion::Throughput::Bytes(xml.len() as u64)); + group.bench_with_input(BenchmarkId::new("xmloxide", name), xml, |b, xml| { + b.iter(|| { + let mut handler = CountingHandler { + elements: 0, + characters: 0, + }; + parse_sax(black_box(xml), &options, &mut handler).expect("SAX parse failed"); + black_box(handler.elements); + }); + }); + } + + group.finish(); +} + +// --------------------------------------------------------------------------- +// Criterion groups and main +// --------------------------------------------------------------------------- + +criterion_group!( + benches, + bench_parse_throughput, + bench_serialize_throughput, + bench_xpath, + bench_sax_streaming, +); + +criterion_main!(benches); diff --git a/browser/vendor/xmloxide/benches/ecosystem_bench.rs b/browser/vendor/xmloxide/benches/ecosystem_bench.rs new file mode 100644 index 000000000..f598d877a --- /dev/null +++ b/browser/vendor/xmloxide/benches/ecosystem_bench.rs @@ -0,0 +1,252 @@ +//! Head-to-head benchmarks comparing xmloxide against roxmltree and quick-xml. +//! +//! Run with: `cargo bench --features bench-rust-xml --bench ecosystem_bench` +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::fmt::Write; + +use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; + +use xmloxide::Document; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const ATOM_FEED: &str = include_str!("fixtures/atom_feed.xml"); +const SVG_DRAWING: &str = include_str!("fixtures/svg_drawing.xml"); +const MAVEN_POM: &str = include_str!("fixtures/maven_pom.xml"); + +/// Generates a large XML document at runtime (~100KB). +fn make_large_xml() -> String { + let mut xml = String::from("<?xml version=\"1.0\"?>\n<database>\n"); + for i in 0..2000 { + let _ = writeln!( + xml, + " <record id=\"{i}\" status=\"active\" priority=\"{}\">\ + <name>Record {i}</name>\ + <value>{}</value>\ + <description>Description for record {i}.</description>\ + </record>", + i % 5, + i * 42 + ); + } + xml.push_str("</database>\n"); + xml +} + +// --------------------------------------------------------------------------- +// Parse benchmarks +// --------------------------------------------------------------------------- + +fn bench_parse_throughput(c: &mut Criterion) { + let large = make_large_xml(); + + let fixtures: Vec<(&str, &str)> = vec![ + ("atom_feed", ATOM_FEED), + ("svg_drawing", SVG_DRAWING), + ("maven_pom", MAVEN_POM), + ("large_2000", &large), + ]; + + let mut group = c.benchmark_group("parse"); + + for (name, xml) in &fixtures { + let bytes = xml.len() as u64; + group.throughput(Throughput::Bytes(bytes)); + + group.bench_with_input(BenchmarkId::new("xmloxide", name), xml, |b, xml| { + b.iter(|| { + let doc = Document::parse_str(black_box(xml)).unwrap(); + black_box(doc.root_element()); + }); + }); + + group.bench_with_input(BenchmarkId::new("roxmltree", name), xml, |b, xml| { + b.iter(|| { + let doc = roxmltree::Document::parse(black_box(xml)).unwrap(); + black_box(doc.root_element()); + }); + }); + + group.bench_with_input(BenchmarkId::new("quick-xml/reader", name), xml, |b, xml| { + b.iter(|| { + use quick_xml::events::Event; + use quick_xml::Reader; + let mut reader = Reader::from_str(black_box(xml)); + let mut count = 0u64; + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Eof) => break, + Ok(_) => count += 1, + Err(e) => panic!("quick-xml error: {e}"), + } + buf.clear(); + } + black_box(count); + }); + }); + } + + group.finish(); +} + +// --------------------------------------------------------------------------- +// Tree navigation benchmarks +// --------------------------------------------------------------------------- + +fn bench_tree_walk(c: &mut Criterion) { + let large = make_large_xml(); + + let mut group = c.benchmark_group("tree_walk"); + + // xmloxide: walk all nodes and count elements + group.bench_function("xmloxide", |b| { + let doc = Document::parse_str(&large).unwrap(); + let root = doc.root_element().unwrap(); + b.iter(|| { + let mut count = 0u64; + for node in doc.descendants(black_box(root)) { + if doc.is_element(node) { + count += 1; + } + } + black_box(count) + }); + }); + + // roxmltree: walk all nodes and count elements + group.bench_function("roxmltree", |b| { + let doc = roxmltree::Document::parse(&large).unwrap(); + let root = doc.root_element(); + b.iter(|| { + let mut count = 0u64; + for node in black_box(root).descendants() { + if node.is_element() { + count += 1; + } + } + black_box(count) + }); + }); + + group.finish(); +} + +// --------------------------------------------------------------------------- +// Attribute access benchmarks +// --------------------------------------------------------------------------- + +fn bench_attr_access(c: &mut Criterion) { + let large = make_large_xml(); + + let mut group = c.benchmark_group("attr_access"); + + // xmloxide: look up 'id' attribute on every element + group.bench_function("xmloxide", |b| { + let doc = Document::parse_str(&large).unwrap(); + let root = doc.root_element().unwrap(); + b.iter(|| { + let mut count = 0u64; + for node in doc.descendants(black_box(root)) { + if doc.attribute(node, "id").is_some() { + count += 1; + } + } + black_box(count) + }); + }); + + // roxmltree: look up 'id' attribute on every element + group.bench_function("roxmltree", |b| { + let doc = roxmltree::Document::parse(&large).unwrap(); + let root = doc.root_element(); + b.iter(|| { + let mut count = 0u64; + for node in black_box(root).descendants() { + if node.attribute("id").is_some() { + count += 1; + } + } + black_box(count) + }); + }); + + group.finish(); +} + +// --------------------------------------------------------------------------- +// Serialization benchmarks +// --------------------------------------------------------------------------- + +fn bench_serialize(c: &mut Criterion) { + let large = make_large_xml(); + + let mut group = c.benchmark_group("serialize"); + group.throughput(Throughput::Bytes(large.len() as u64)); + + // xmloxide: serialize + group.bench_function("xmloxide", |b| { + let doc = Document::parse_str(&large).unwrap(); + b.iter(|| { + let out = xmloxide::serial::serialize(black_box(&doc)); + black_box(out.len()); + }); + }); + + // roxmltree doesn't have serialization, so we only compare xmloxide here + // quick-xml writer is a different API (not DOM-to-string) + + group.finish(); +} + +// --------------------------------------------------------------------------- +// CSS selector benchmarks (xmloxide only — others don't have CSS) +// --------------------------------------------------------------------------- + +fn bench_css_selector(c: &mut Criterion) { + let large = make_large_xml(); + + let mut group = c.benchmark_group("css_selector"); + + group.bench_function("xmloxide/tag", |b| { + let doc = Document::parse_str(&large).unwrap(); + let root = doc.root_element().unwrap(); + b.iter(|| { + let results = xmloxide::css::select(black_box(&doc), root, "record").unwrap(); + black_box(results.len()); + }); + }); + + group.bench_function("xmloxide/attr", |b| { + let doc = Document::parse_str(&large).unwrap(); + let root = doc.root_element().unwrap(); + b.iter(|| { + let results = xmloxide::css::select(black_box(&doc), root, "[priority=\"0\"]").unwrap(); + black_box(results.len()); + }); + }); + + group.bench_function("xmloxide/complex", |b| { + let doc = Document::parse_str(&large).unwrap(); + let root = doc.root_element().unwrap(); + b.iter(|| { + let results = xmloxide::css::select(black_box(&doc), root, "record > name").unwrap(); + black_box(results.len()); + }); + }); + + group.finish(); +} + +criterion_group!( + benches, + bench_parse_throughput, + bench_tree_walk, + bench_attr_access, + bench_serialize, + bench_css_selector, +); +criterion_main!(benches); diff --git a/browser/vendor/xmloxide/benches/parser_bench.rs b/browser/vendor/xmloxide/benches/parser_bench.rs new file mode 100644 index 000000000..29445f34b --- /dev/null +++ b/browser/vendor/xmloxide/benches/parser_bench.rs @@ -0,0 +1,626 @@ +#![allow(clippy::expect_used)] + +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use std::fmt::Write; +use xmloxide::css; +use xmloxide::html::parse_html; +use xmloxide::html5::{parse_html5, parse_html5_with_options, Html5ParseOptions}; +use xmloxide::parser::{ParseOptions, PushParser}; +use xmloxide::reader::XmlReader; +use xmloxide::sax::{parse_sax, SaxHandler}; +use xmloxide::serial::serialize; +use xmloxide::validation::{dtd, relaxng, schematron, xsd}; +use xmloxide::xpath::evaluate; +use xmloxide::Document; + +// --------------------------------------------------------------------------- +// Document generators +// --------------------------------------------------------------------------- + +/// Generates a small XML document with approximately 10 elements. +fn make_small_xml() -> String { + let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root>\n"); + for i in 0..10 { + let _ = writeln!(xml, " <item id=\"{i}\">Value {i}</item>"); + } + xml.push_str("</root>\n"); + xml +} + +/// Generates a medium XML document with approximately 100 elements. +fn make_medium_xml() -> String { + let mut xml = String::from("<?xml version=\"1.0\"?>\n<catalog>\n"); + for i in 0..100 { + let _ = writeln!( + xml, + " <book id=\"bk{i}\"><title>Title {i}</title>\ + <author>Author {i}</author>\ + <price>{}.99</price></book>", + 10 + i + ); + } + xml.push_str("</catalog>\n"); + xml +} + +/// Generates a large XML document with approximately 1000 elements. +fn make_large_xml() -> String { + let mut xml = String::from("<?xml version=\"1.0\"?>\n<database>\n"); + for i in 0..1000 { + let _ = writeln!( + xml, + " <record id=\"{i}\"><name>Record {i}</name>\ + <value>{}</value><status>active</status></record>", + i * 42 + ); + } + xml.push_str("</database>\n"); + xml +} + +/// Generates a deeply nested XML document with the given nesting depth. +fn make_nested_xml(depth: usize) -> String { + let mut xml = String::from("<?xml version=\"1.0\"?>\n"); + for i in 0..depth { + let _ = write!(xml, "<level{i}>"); + } + xml.push_str("leaf"); + for i in (0..depth).rev() { + let _ = write!(xml, "</level{i}>"); + } + xml.push('\n'); + xml +} + +/// Generates an XML document where each element has `num_attrs` attributes. +fn make_attr_heavy_xml(num_attrs: usize) -> String { + let mut xml = String::from("<?xml version=\"1.0\"?>\n<root>\n"); + for i in 0..10 { + let _ = write!(xml, " <element"); + for j in 0..num_attrs { + let _ = write!(xml, " attr{j}=\"value_{i}_{j}\""); + } + xml.push_str("/>\n"); + } + xml.push_str("</root>\n"); + xml +} + +/// Generates an XML document with many namespace declarations and prefixed +/// elements. +fn make_namespace_heavy_xml() -> String { + let mut xml = String::from("<?xml version=\"1.0\"?>\n<root"); + for i in 0..20 { + let _ = write!(xml, " xmlns:ns{i}=\"http://example.com/ns{i}\""); + } + xml.push_str(">\n"); + for i in 0..100 { + let ns = i % 20; + let _ = writeln!( + xml, + " <ns{ns}:item ns{ns}:id=\"{i}\">Content {i}</ns{ns}:item>" + ); + } + xml.push_str("</root>\n"); + xml +} + +/// Generates an HTML document for benchmarking the HTML parser. +fn make_html_doc() -> String { + let mut html = String::from( + "<!DOCTYPE html>\n<html>\n<head>\n\ + <title>Benchmark Page</title>\n\ + <meta charset=\"utf-8\">\n\ + <link rel=\"stylesheet\" href=\"style.css\">\n\ + </head>\n<body>\n<h1>Benchmark</h1>\n", + ); + for i in 0..50 { + let _ = writeln!( + html, + "<div class=\"section\" id=\"s{i}\">\ + <p>Paragraph {i} with <b>bold</b> and <i>italic</i> text.</p>\ + <ul><li>Item A</li><li>Item B</li><li>Item C</li></ul>\ + <img src=\"img{i}.png\" alt=\"Image {i}\">\ + <a href=\"#s{i}\">Link {i}</a>\ + </div>" + ); + } + html.push_str("</body>\n</html>\n"); + html +} + +/// Generates a medium XML document suitable for `XPath` benchmarks, with a +/// structure that exercises path navigation and predicates. +fn make_xpath_xml() -> String { + let mut xml = String::from( + "<?xml version=\"1.0\"?>\n\ + <library>\n", + ); + for i in 0..50 { + let genre = match i % 4 { + 0 => "fiction", + 1 => "science", + 2 => "history", + _ => "poetry", + }; + let _ = writeln!( + xml, + " <book genre=\"{genre}\" id=\"{i}\">\ + <title>Book {i}</title>\ + <author>Author {}</author>\ + <year>{}</year>\ + <price>{}.99</price>\ + </book>", + i % 10, + 2000 + i, + 10 + i + ); + } + xml.push_str("</library>\n"); + xml +} + +// --------------------------------------------------------------------------- +// XML Parsing benchmarks +// --------------------------------------------------------------------------- + +fn bench_parse_small(c: &mut Criterion) { + let xml = make_small_xml(); + c.bench_function("parse_small", |b| { + b.iter(|| Document::parse_str(black_box(&xml))); + }); +} + +fn bench_parse_medium(c: &mut Criterion) { + let xml = make_medium_xml(); + c.bench_function("parse_medium", |b| { + b.iter(|| Document::parse_str(black_box(&xml))); + }); +} + +fn bench_parse_large(c: &mut Criterion) { + let xml = make_large_xml(); + c.bench_function("parse_large", |b| { + b.iter(|| Document::parse_str(black_box(&xml))); + }); +} + +fn bench_parse_deeply_nested(c: &mut Criterion) { + let xml = make_nested_xml(50); + c.bench_function("parse_deeply_nested", |b| { + b.iter(|| Document::parse_str(black_box(&xml))); + }); +} + +fn bench_parse_many_attributes(c: &mut Criterion) { + let xml = make_attr_heavy_xml(50); + c.bench_function("parse_many_attributes", |b| { + b.iter(|| Document::parse_str(black_box(&xml))); + }); +} + +fn bench_parse_namespace_heavy(c: &mut Criterion) { + let xml = make_namespace_heavy_xml(); + c.bench_function("parse_namespace_heavy", |b| { + b.iter(|| Document::parse_str(black_box(&xml))); + }); +} + +// --------------------------------------------------------------------------- +// Serialization benchmarks +// --------------------------------------------------------------------------- + +fn bench_serialize_small(c: &mut Criterion) { + let xml = make_small_xml(); + let doc = Document::parse_str(&xml).expect("failed to parse small XML"); + c.bench_function("serialize_small", |b| { + b.iter(|| serialize(black_box(&doc))); + }); +} + +fn bench_serialize_large(c: &mut Criterion) { + let xml = make_large_xml(); + let doc = Document::parse_str(&xml).expect("failed to parse large XML"); + c.bench_function("serialize_large", |b| { + b.iter(|| serialize(black_box(&doc))); + }); +} + +// --------------------------------------------------------------------------- +// HTML parsing benchmark +// --------------------------------------------------------------------------- + +fn bench_parse_html(c: &mut Criterion) { + let html = make_html_doc(); + c.bench_function("parse_html", |b| { + b.iter(|| parse_html(black_box(&html))); + }); +} + +// --------------------------------------------------------------------------- +// SAX parsing benchmark +// --------------------------------------------------------------------------- + +/// A minimal SAX handler that counts elements, used for benchmarking the SAX +/// parsing path without allocation overhead from recording events. +struct CountingHandler { + elements: u64, + characters: u64, +} + +impl SaxHandler for CountingHandler { + fn start_element( + &mut self, + _local_name: &str, + _prefix: Option<&str>, + _namespace: Option<&str>, + _attributes: &[(String, String, Option<String>, Option<String>)], + ) { + self.elements += 1; + } + + fn characters(&mut self, _content: &str) { + self.characters += 1; + } +} + +fn bench_sax_parse(c: &mut Criterion) { + let xml = make_medium_xml(); + let options = ParseOptions::default(); + c.bench_function("sax_parse", |b| { + b.iter(|| { + let mut handler = CountingHandler { + elements: 0, + characters: 0, + }; + parse_sax(black_box(&xml), &options, &mut handler).expect("SAX parse failed"); + black_box(handler.elements); + }); + }); +} + +// --------------------------------------------------------------------------- +// XmlReader benchmark +// --------------------------------------------------------------------------- + +fn bench_reader_parse(c: &mut Criterion) { + let xml = make_medium_xml(); + c.bench_function("reader_parse", |b| { + b.iter(|| { + let mut reader = XmlReader::new(black_box(&xml)); + let mut count: u64 = 0; + while reader.read().expect("reader failed") { + count += 1; + } + black_box(count); + }); + }); +} + +// --------------------------------------------------------------------------- +// XPath benchmarks +// --------------------------------------------------------------------------- + +fn bench_xpath_simple(c: &mut Criterion) { + let xml = make_xpath_xml(); + let doc = Document::parse_str(&xml).expect("failed to parse XPath XML"); + let root = doc.root_element().expect("no root element"); + c.bench_function("xpath_simple", |b| { + b.iter(|| evaluate(black_box(&doc), root, "//book/title")); + }); +} + +fn bench_xpath_complex(c: &mut Criterion) { + let xml = make_xpath_xml(); + let doc = Document::parse_str(&xml).expect("failed to parse XPath XML"); + let root = doc.root_element().expect("no root element"); + c.bench_function("xpath_complex", |b| { + b.iter(|| { + evaluate( + black_box(&doc), + root, + "//book[@genre='fiction' and number(price) > 20]/title", + ) + }); + }); +} + +// --------------------------------------------------------------------------- +// Roundtrip benchmark: parse -> serialize -> parse +// --------------------------------------------------------------------------- + +fn bench_roundtrip(c: &mut Criterion) { + let xml = make_medium_xml(); + c.bench_function("roundtrip", |b| { + b.iter(|| { + let doc = Document::parse_str(black_box(&xml)).expect("parse failed"); + let serialized = serialize(&doc); + let doc2 = Document::parse_str(&serialized).expect("re-parse failed"); + black_box(doc2); + }); + }); +} + +// --------------------------------------------------------------------------- +// Push parser benchmark +// --------------------------------------------------------------------------- + +fn bench_push_parser(c: &mut Criterion) { + let xml = make_medium_xml(); + let bytes = xml.as_bytes(); + // Split into ~64-byte chunks to simulate incremental feeding. + let chunk_size = 64; + let chunks: Vec<&[u8]> = bytes.chunks(chunk_size).collect(); + c.bench_function("push_parser", |b| { + b.iter(|| { + let mut parser = PushParser::new(); + for chunk in &chunks { + parser.push(black_box(chunk)); + } + parser.finish().expect("push parse failed") + }); + }); +} + +// --------------------------------------------------------------------------- +// Criterion groups and main +// --------------------------------------------------------------------------- + +criterion_group!( + parsing, + bench_parse_small, + bench_parse_medium, + bench_parse_large, + bench_parse_deeply_nested, + bench_parse_many_attributes, + bench_parse_namespace_heavy, +); + +criterion_group!(serialization, bench_serialize_small, bench_serialize_large,); + +// --------------------------------------------------------------------------- +// HTML5 parsing benchmarks +// --------------------------------------------------------------------------- + +fn bench_parse_html5(c: &mut Criterion) { + let html = make_html_doc(); + c.bench_function("parse_html5", |b| { + b.iter(|| parse_html5(black_box(&html))); + }); +} + +fn bench_parse_html5_fragment(c: &mut Criterion) { + let html = make_html_doc(); + let opts = Html5ParseOptions { + scripting: false, + fragment_context: Some("body".to_string()), + }; + c.bench_function("parse_html5_fragment", |b| { + b.iter(|| parse_html5_with_options(black_box(&html), &opts)); + }); +} + +// --------------------------------------------------------------------------- +// Additional XPath benchmarks +// --------------------------------------------------------------------------- + +fn bench_xpath_count(c: &mut Criterion) { + let xml = make_xpath_xml(); + let doc = Document::parse_str(&xml).expect("failed to parse XPath XML"); + let root = doc.root_element().expect("no root element"); + c.bench_function("xpath_count", |b| { + b.iter(|| evaluate(black_box(&doc), root, "count(//book)")); + }); +} + +fn bench_xpath_string_function(c: &mut Criterion) { + let xml = make_xpath_xml(); + let doc = Document::parse_str(&xml).expect("failed to parse XPath XML"); + let root = doc.root_element().expect("no root element"); + c.bench_function("xpath_string_function", |b| { + b.iter(|| evaluate(black_box(&doc), root, "string(//book[1]/title)")); + }); +} + +fn bench_xpath_position_predicate(c: &mut Criterion) { + let xml = make_xpath_xml(); + let doc = Document::parse_str(&xml).expect("failed to parse XPath XML"); + let root = doc.root_element().expect("no root element"); + c.bench_function("xpath_position_predicate", |b| { + b.iter(|| { + evaluate( + black_box(&doc), + root, + "//book[position() > 10 and position() < 20]", + ) + }); + }); +} + +fn bench_xpath_ancestor(c: &mut Criterion) { + let xml = make_xpath_xml(); + let doc = Document::parse_str(&xml).expect("failed to parse XPath XML"); + let root = doc.root_element().expect("no root element"); + // Get a deep node to evaluate ancestor axis from + let result = evaluate(&doc, root, "//book[1]/title").expect("xpath failed"); + let title_node = result.as_node_set().expect("expected nodeset")[0].anchor(); + c.bench_function("xpath_ancestor", |b| { + b.iter(|| evaluate(black_box(&doc), title_node, "ancestor::*")); + }); +} + +fn bench_xpath_union(c: &mut Criterion) { + let xml = make_xpath_xml(); + let doc = Document::parse_str(&xml).expect("failed to parse XPath XML"); + let root = doc.root_element().expect("no root element"); + c.bench_function("xpath_union", |b| { + b.iter(|| evaluate(black_box(&doc), root, "//title | //author | //year")); + }); +} + +// --------------------------------------------------------------------------- +// Validation benchmarks +// --------------------------------------------------------------------------- + +/// DTD for validating the medium XML (catalog of books). +fn make_book_dtd() -> String { + String::from( + "<!ELEMENT catalog (book*)>\n\ + <!ELEMENT book (title, author, price)>\n\ + <!ATTLIST book id ID #REQUIRED>\n\ + <!ELEMENT title (#PCDATA)>\n\ + <!ELEMENT author (#PCDATA)>\n\ + <!ELEMENT price (#PCDATA)>\n", + ) +} + +fn bench_validate_dtd(c: &mut Criterion) { + let xml = make_medium_xml(); + let dtd_str = make_book_dtd(); + let dtd_schema = dtd::parse_dtd(&dtd_str).expect("DTD parse failed"); + c.bench_function("validate_dtd", |b| { + b.iter(|| { + let mut doc = Document::parse_str(black_box(&xml)).expect("parse failed"); + dtd::validate(black_box(&mut doc), &dtd_schema) + }); + }); +} + +fn bench_validate_relaxng(c: &mut Criterion) { + let schema_xml = r#"<?xml version="1.0"?> +<element name="catalog" xmlns="http://relaxng.org/ns/structure/1.0"> + <zeroOrMore> + <element name="book"> + <attribute name="id"/> + <element name="title"><text/></element> + <element name="author"><text/></element> + <element name="price"><text/></element> + </element> + </zeroOrMore> +</element>"#; + let schema = relaxng::parse_relaxng(schema_xml).expect("RelaxNG parse failed"); + let xml = make_medium_xml(); + let doc = Document::parse_str(&xml).expect("parse failed"); + c.bench_function("validate_relaxng", |b| { + b.iter(|| relaxng::validate(black_box(&doc), &schema)); + }); +} + +fn bench_validate_xsd(c: &mut Criterion) { + let schema_xml = r#"<?xml version="1.0"?> +<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="catalog"> + <xs:complexType> + <xs:sequence> + <xs:element name="book" maxOccurs="unbounded" minOccurs="0"> + <xs:complexType> + <xs:sequence> + <xs:element name="title" type="xs:string"/> + <xs:element name="author" type="xs:string"/> + <xs:element name="price" type="xs:string"/> + </xs:sequence> + <xs:attribute name="id" type="xs:string" use="required"/> + </xs:complexType> + </xs:element> + </xs:sequence> + </xs:complexType> + </xs:element> +</xs:schema>"#; + let schema = xsd::parse_xsd(schema_xml).expect("XSD parse failed"); + let xml = make_medium_xml(); + let doc = Document::parse_str(&xml).expect("parse failed"); + c.bench_function("validate_xsd", |b| { + b.iter(|| xsd::validate_xsd(black_box(&doc), &schema)); + }); +} + +fn bench_validate_schematron(c: &mut Criterion) { + let schema_xml = r#"<schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="book"> + <assert test="title">book must have a title</assert> + <assert test="author">book must have an author</assert> + <assert test="@id">book must have an id attribute</assert> + </rule> + </pattern> +</schema>"#; + let schema = schematron::parse_schematron(schema_xml).expect("Schematron parse failed"); + let xml = make_medium_xml(); + let doc = Document::parse_str(&xml).expect("parse failed"); + c.bench_function("validate_schematron", |b| { + b.iter(|| schematron::validate_schematron(black_box(&doc), &schema)); + }); +} + +// --------------------------------------------------------------------------- +// CSS selector benchmark +// --------------------------------------------------------------------------- + +fn bench_css_select(c: &mut Criterion) { + let html = make_html_doc(); + let doc = parse_html5(&html).expect("html5 parse failed"); + let root = doc.root_element().expect("no root"); + c.bench_function("css_select_class", |b| { + b.iter(|| css::select(black_box(&doc), root, "div.section")); + }); +} + +fn bench_css_select_complex(c: &mut Criterion) { + let html = make_html_doc(); + let doc = parse_html5(&html).expect("html5 parse failed"); + let root = doc.root_element().expect("no root"); + c.bench_function("css_select_complex", |b| { + b.iter(|| css::select(black_box(&doc), root, "div.section > p > b")); + }); +} + +// --------------------------------------------------------------------------- +// Criterion groups and main +// --------------------------------------------------------------------------- + +criterion_group!(html_parsing, bench_parse_html); + +criterion_group!(html5_parsing, bench_parse_html5, bench_parse_html5_fragment); + +criterion_group!(sax, bench_sax_parse); + +criterion_group!(reader, bench_reader_parse); + +criterion_group!( + xpath, + bench_xpath_simple, + bench_xpath_complex, + bench_xpath_count, + bench_xpath_string_function, + bench_xpath_position_predicate, + bench_xpath_ancestor, + bench_xpath_union, +); + +criterion_group!(roundtrip, bench_roundtrip); + +criterion_group!(push, bench_push_parser); + +criterion_group!( + validation, + bench_validate_dtd, + bench_validate_relaxng, + bench_validate_xsd, + bench_validate_schematron, +); + +criterion_group!(css_selectors, bench_css_select, bench_css_select_complex,); + +criterion_main!( + parsing, + serialization, + html_parsing, + html5_parsing, + sax, + reader, + xpath, + roundtrip, + push, + validation, + css_selectors, +); diff --git a/browser/vendor/xmloxide/examples/basic_parse.rs b/browser/vendor/xmloxide/examples/basic_parse.rs new file mode 100644 index 000000000..e9d2dc999 --- /dev/null +++ b/browser/vendor/xmloxide/examples/basic_parse.rs @@ -0,0 +1,54 @@ +//! Basic XML parsing and tree navigation. +//! +//! Run with: `cargo run --example basic_parse` +#![allow(clippy::expect_used)] + +use xmloxide::tree::NodeKind; +use xmloxide::Document; + +fn main() { + let xml = r#"<?xml version="1.0"?> +<bookstore> + <book category="fiction"> + <title lang="en">The Great Gatsby</title> + <author>F. Scott Fitzgerald</author> + <year>1925</year> + <price>10.99</price> + </book> + <book category="science"> + <title lang="en">A Brief History of Time</title> + <author>Stephen Hawking</author> + <year>1988</year> + <price>14.99</price> + </book> +</bookstore>"#; + + let doc = Document::parse_str(xml).expect("failed to parse XML"); + let root = doc.root_element().expect("no root element"); + + println!("Root element: {}", doc.node_name(root).unwrap_or("?")); + + // Iterate over child elements + for child in doc.children(root) { + if let NodeKind::Element { + ref name, + ref attributes, + .. + } = doc.node(child).kind + { + let category = attributes + .iter() + .find(|a| a.name == "category") + .map_or("unknown", |a| a.value.as_str()); + println!("\n<{name}> (category={category})"); + + // Print child elements + for grandchild in doc.children(child) { + if let NodeKind::Element { ref name, .. } = doc.node(grandchild).kind { + let text = doc.text_content(grandchild); + println!(" {name}: {text}"); + } + } + } + } +} diff --git a/browser/vendor/xmloxide/examples/c14n.rs b/browser/vendor/xmloxide/examples/c14n.rs new file mode 100644 index 000000000..c5e84fe75 --- /dev/null +++ b/browser/vendor/xmloxide/examples/c14n.rs @@ -0,0 +1,45 @@ +//! Canonical XML (C14N) serialization example. +//! +//! Canonical XML produces a deterministic byte-for-byte representation +//! of an XML document, commonly used in digital signatures (XML-DSIG). +//! +//! Run with: `cargo run --example c14n` +#![allow(clippy::expect_used)] + +use xmloxide::serial::c14n::{canonicalize, C14nOptions}; +use xmloxide::Document; + +fn main() { + // Input with varied formatting, attribute order, and extra whitespace + let xml = r#"<?xml version="1.0" encoding="UTF-8"?> +<!-- This comment will be stripped by default C14N --> +<doc xmlns:b="http://example.com/b" xmlns:a="http://example.com/a"> + <item z="3" a="1" m="2" > + <![CDATA[Some text]]> + </item> + <empty/> + <b:element b:attr="val"/> +</doc>"#; + + let doc = Document::parse_str(xml).expect("parse failed"); + + // Standard C14N (default includes comments, sorts attributes and namespaces) + let c14n = canonicalize(&doc, &C14nOptions::default()); + println!("=== C14N (with comments, default) ===\n{c14n}\n"); + + // C14N without comments + let opts_no_comments = C14nOptions { + with_comments: false, + ..C14nOptions::default() + }; + let c14n_no_comments = canonicalize(&doc, &opts_no_comments); + println!("=== C14N (without comments) ===\n{c14n_no_comments}\n"); + + // Exclusive C14N (namespace-aware, used in XML signatures) + let opts_exclusive = C14nOptions { + exclusive: true, + ..C14nOptions::default() + }; + let c14n_exc = canonicalize(&doc, &opts_exclusive); + println!("=== Exclusive C14N ===\n{c14n_exc}"); +} diff --git a/browser/vendor/xmloxide/examples/error_recovery.rs b/browser/vendor/xmloxide/examples/error_recovery.rs new file mode 100644 index 000000000..7543003f6 --- /dev/null +++ b/browser/vendor/xmloxide/examples/error_recovery.rs @@ -0,0 +1,48 @@ +//! Demonstrates error recovery on malformed XML. +//! +//! Run with: `cargo run --example error_recovery` +#![allow(clippy::expect_used)] + +use xmloxide::parser::{parse_str_with_options, ParseOptions}; +use xmloxide::serial::serialize; + +fn main() { + let malformed_docs = vec![ + ("Missing end tag", "<root><child>text</root>"), + ("Unclosed root", "<root><a>hello</a><b>world</b>"), + ( + "Duplicate attributes", + "<root attr=\"1\" attr=\"2\">text</root>", + ), + ("Mismatched tags", "<a><b>text</a></b>"), + ( + "Invalid characters", + "<root>valid text <!-- comment --> more</root>", + ), + ]; + + let opts = ParseOptions::default().recover(true); + + for (label, xml) in malformed_docs { + println!("=== {label} ==="); + println!("Input: {xml}"); + + match parse_str_with_options(xml, &opts) { + Ok(doc) => { + let output = serialize(&doc); + println!("Output: {output}"); + if doc.diagnostics.is_empty() { + println!("(no diagnostics)"); + } else { + for diag in &doc.diagnostics { + println!(" warning: {diag}"); + } + } + } + Err(e) => { + println!("Fatal error: {e}"); + } + } + println!(); + } +} diff --git a/browser/vendor/xmloxide/examples/ffi_usage.c b/browser/vendor/xmloxide/examples/ffi_usage.c new file mode 100644 index 000000000..c7c50b6dc --- /dev/null +++ b/browser/vendor/xmloxide/examples/ffi_usage.c @@ -0,0 +1,89 @@ +/* + * ffi_usage.c — Example of using xmloxide from C + * + * Build: + * # First build the shared library: + * cargo rustc --lib --release --features ffi -- --crate-type cdylib + * + * # Then compile this example (adjust library path as needed): + * cc -o ffi_usage examples/ffi_usage.c -Iinclude \ + * -Ltarget/release -lxmloxide -lpthread -ldl -lm + * + * # On macOS, also pass: -framework Security + * # Run with: LD_LIBRARY_PATH=target/release ./ffi_usage + * # or: DYLD_LIBRARY_PATH=target/release ./ffi_usage + */ + +#include <stdio.h> +#include <stdlib.h> +#include "xmloxide.h" + +int main(void) { + /* --- Parse an XML document --- */ + const char *xml = "<library>" + " <book id=\"1\"><title>The Rust Programming Language</title></book>" + " <book id=\"2\"><title>Programming Rust</title></book>" + "</library>"; + + xmloxide_document *doc = xmloxide_parse_str(xml); + if (!doc) { + fprintf(stderr, "Parse error: %s\n", xmloxide_last_error()); + return 1; + } + + /* --- Navigate the tree --- */ + uint32_t root = xmloxide_doc_root_element(doc); + char *root_name = xmloxide_node_name(doc, root); + printf("Root element: %s\n", root_name); + xmloxide_free_string(root_name); + + /* Iterate children */ + uint32_t child = xmloxide_node_first_child(doc, root); + while (child) { + if (xmloxide_node_type(doc, child) == XMLOXIDE_NODE_ELEMENT) { + char *name = xmloxide_node_name(doc, child); + char *id = xmloxide_node_attribute(doc, child, "id"); + char *text = xmloxide_node_text_content(doc, child); + printf(" <%s id=\"%s\">%s</%s>\n", name, id ? id : "", text, name); + xmloxide_free_string(name); + xmloxide_free_string(id); + xmloxide_free_string(text); + } + child = xmloxide_node_next_sibling(doc, child); + } + + /* --- XPath query --- */ + xmloxide_xpath_value *result = xmloxide_xpath_eval(doc, 0, "count(//book)"); + if (result) { + printf("Book count: %.0f\n", xmloxide_xpath_result_number(result)); + xmloxide_xpath_free_result(result); + } + + /* --- Serialize --- */ + char *output = xmloxide_serialize(doc); + printf("Serialized: %s\n", output); + xmloxide_free_string(output); + + /* --- Pretty-print --- */ + char *pretty = xmloxide_serialize_pretty(doc); + printf("Pretty:\n%s\n", pretty); + xmloxide_free_string(pretty); + + /* --- Mutate the tree --- */ + uint32_t new_book = xmloxide_create_element(doc, "book"); + xmloxide_set_attribute(doc, new_book, "id", "3"); + uint32_t title = xmloxide_create_element(doc, "title"); + uint32_t title_text = xmloxide_create_text(doc, "Zero To Production"); + xmloxide_append_child(doc, title, title_text); + xmloxide_append_child(doc, new_book, title); + xmloxide_append_child(doc, root, new_book); + + char *after = xmloxide_serialize(doc); + printf("After mutation: %s\n", after); + xmloxide_free_string(after); + + xmloxide_free_doc(doc); + + printf("Done.\n"); + return 0; +} diff --git a/browser/vendor/xmloxide/examples/html_parse.rs b/browser/vendor/xmloxide/examples/html_parse.rs new file mode 100644 index 000000000..ee7a3c79e --- /dev/null +++ b/browser/vendor/xmloxide/examples/html_parse.rs @@ -0,0 +1,65 @@ +//! HTML parsing example demonstrating error-tolerant parsing. +//! +//! Run with: `cargo run --example html_parse` +#![allow(clippy::expect_used)] + +use xmloxide::html::parse_html; +use xmloxide::tree::NodeKind; +use xmloxide::Document; + +fn print_tree(doc: &Document, node: xmloxide::NodeId, depth: usize) { + let indent = " ".repeat(depth); + let node_data = doc.node(node); + match &node_data.kind { + NodeKind::Element { + name, attributes, .. + } => { + if attributes.is_empty() { + println!("{indent}<{name}>"); + } else { + let attrs: Vec<String> = attributes + .iter() + .map(|a| format!("{}=\"{}\"", a.name, a.value)) + .collect(); + println!("{indent}<{name} {}>", attrs.join(" ")); + } + for child in doc.children(node) { + print_tree(doc, child, depth + 1); + } + } + NodeKind::Text { content } => { + let trimmed = content.trim(); + if !trimmed.is_empty() { + println!("{indent}\"{trimmed}\""); + } + } + _ => { + for child in doc.children(node) { + print_tree(doc, child, depth + 1); + } + } + } +} + +fn main() { + // HTML with missing tags, unclosed elements, void elements + let html = r#" +<p>Hello <b>bold <i>and italic</b> text</i> +<br> +<img src="photo.jpg" alt="A photo"> +<ul> + <li>First item + <li>Second item + <li>Third item +</ul> +<div>Unclosed div +"#; + + println!("Input HTML:"); + println!("{html}"); + println!("Parsed tree:"); + + let doc = parse_html(html).expect("HTML parsing failed"); + let root = doc.root_element().expect("no root"); + print_tree(&doc, root, 0); +} diff --git a/browser/vendor/xmloxide/examples/push_parser.rs b/browser/vendor/xmloxide/examples/push_parser.rs new file mode 100644 index 000000000..b8acadc95 --- /dev/null +++ b/browser/vendor/xmloxide/examples/push_parser.rs @@ -0,0 +1,65 @@ +//! Push/incremental parser example. +//! +//! The push parser accepts XML data in arbitrarily sized chunks, useful +//! when data arrives incrementally (network sockets, streaming, etc.). +//! +//! Run with: `cargo run --example push_parser` +#![allow(clippy::expect_used)] + +use xmloxide::parser::PushParser; +use xmloxide::tree::NodeKind; + +fn main() { + // Simulate receiving XML in chunks (e.g., from a network stream) + let chunks = [ + b"<?xml version=\"1.0\"?>" as &[u8], + b"<inventory>", + b" <item sku=\"A10", + b"1\"><name>Bolt</name>", + b"<qty>500</qty></item>", + b" <item sku=\"B202\">", + b"<name>Nut</name><qty>", + b"1000</qty></item>", + b"</inventory>", + ]; + + let mut parser = PushParser::new(); + + for (i, chunk) in chunks.iter().enumerate() { + parser.push(chunk); + println!( + "Pushed chunk {} ({} bytes, {} total buffered)", + i + 1, + chunk.len(), + parser.buffered_bytes() + ); + } + + let doc = parser.finish().expect("parsing failed"); + let root = doc.root_element().expect("no root element"); + + println!("\nParsed document:"); + println!("Root: {}", doc.node_name(root).unwrap_or("?")); + + for child in doc.children(root) { + if let NodeKind::Element { + ref name, + ref attributes, + .. + } = doc.node(child).kind + { + let sku = attributes + .iter() + .find(|a| a.name == "sku") + .map_or("?", |a| a.value.as_str()); + print!(" <{name} sku=\"{sku}\">"); + + for field in doc.children(child) { + if let NodeKind::Element { ref name, .. } = doc.node(field).kind { + print!(" {name}={}", doc.text_content(field)); + } + } + println!(); + } + } +} diff --git a/browser/vendor/xmloxide/examples/reader.rs b/browser/vendor/xmloxide/examples/reader.rs new file mode 100644 index 000000000..8d8864656 --- /dev/null +++ b/browser/vendor/xmloxide/examples/reader.rs @@ -0,0 +1,73 @@ +//! Pull-based `XmlReader` streaming example. +//! +//! The `XmlReader` provides a cursor-style interface for reading XML +//! documents one node at a time without building a full DOM tree. +//! +//! Run with: `cargo run --example reader` +#![allow(clippy::expect_used)] + +use xmloxide::reader::{XmlNodeType, XmlReader}; + +fn main() { + let xml = r#"<?xml version="1.0"?> +<catalog> + <product id="1" category="electronics"> + <name>Widget</name> + <price currency="USD">29.99</price> + </product> + <product id="2" category="books"> + <name>XML Handbook</name> + <price currency="USD">49.99</price> + </product> +</catalog>"#; + + let mut reader = XmlReader::new(xml); + let mut depth: usize = 0; + + println!("Walking the XML document node by node:\n"); + + while reader.read().expect("read failed") { + let indent = " ".repeat(depth); + match reader.node_type() { + XmlNodeType::Element => { + let name = reader.name().unwrap_or("?"); + let attr_count = reader.attribute_count(); + if attr_count > 0 { + print!("{indent}<{name}"); + // Walk attributes + if reader.move_to_first_attribute() { + loop { + let aname = reader.name().unwrap_or("?"); + let aval = reader.value().unwrap_or("?"); + print!(" {aname}=\"{aval}\""); + if !reader.move_to_next_attribute() { + break; + } + } + reader.move_to_element(); + } + println!(">"); + } else { + println!("{indent}<{name}>"); + } + if !reader.is_empty_element() { + depth += 1; + } + } + XmlNodeType::EndElement => { + depth -= 1; + let indent = " ".repeat(depth); + let name = reader.name().unwrap_or("?"); + println!("{indent}</{name}>"); + } + XmlNodeType::Text => { + let text = reader.value().unwrap_or(""); + let trimmed = text.trim(); + if !trimmed.is_empty() { + println!("{indent}TEXT: \"{trimmed}\""); + } + } + _ => {} + } + } +} diff --git a/browser/vendor/xmloxide/examples/sax_streaming.rs b/browser/vendor/xmloxide/examples/sax_streaming.rs new file mode 100644 index 000000000..0bed54a64 --- /dev/null +++ b/browser/vendor/xmloxide/examples/sax_streaming.rs @@ -0,0 +1,82 @@ +//! SAX2 streaming parser example. +//! +//! Run with: `cargo run --example sax_streaming` +#![allow(clippy::expect_used)] + +use xmloxide::parser::ParseOptions; +use xmloxide::sax::{parse_sax, SaxHandler}; + +/// A handler that tracks element depth and prints events. +struct PrintHandler { + depth: usize, +} + +impl SaxHandler for PrintHandler { + fn start_document(&mut self) { + println!("--- Document start ---"); + } + + fn end_document(&mut self) { + println!("--- Document end ---"); + } + + fn start_element( + &mut self, + local_name: &str, + prefix: Option<&str>, + _namespace: Option<&str>, + attributes: &[(String, String, Option<String>, Option<String>)], + ) { + let indent = " ".repeat(self.depth); + let name = match prefix { + Some(p) => format!("{p}:{local_name}"), + None => local_name.to_string(), + }; + if attributes.is_empty() { + println!("{indent}<{name}>"); + } else { + let attrs: Vec<String> = attributes + .iter() + .map(|(local, value, _, _)| format!("{local}=\"{value}\"")) + .collect(); + println!("{indent}<{name} {}>", attrs.join(" ")); + } + self.depth += 1; + } + + fn end_element(&mut self, local_name: &str, prefix: Option<&str>, _namespace: Option<&str>) { + self.depth -= 1; + let indent = " ".repeat(self.depth); + let name = match prefix { + Some(p) => format!("{p}:{local_name}"), + None => local_name.to_string(), + }; + println!("{indent}</{name}>"); + } + + fn characters(&mut self, content: &str) { + let trimmed = content.trim(); + if !trimmed.is_empty() { + let indent = " ".repeat(self.depth); + println!("{indent}TEXT: \"{trimmed}\""); + } + } +} + +fn main() { + let xml = r#"<?xml version="1.0"?> +<catalog> + <product id="1" category="electronics"> + <name>Widget</name> + <price>29.99</price> + </product> + <product id="2" category="books"> + <name>XML Handbook</name> + <price>49.99</price> + </product> +</catalog>"#; + + let mut handler = PrintHandler { depth: 0 }; + let options = ParseOptions::default(); + parse_sax(xml, &options, &mut handler).expect("SAX parsing failed"); +} diff --git a/browser/vendor/xmloxide/examples/serialize.rs b/browser/vendor/xmloxide/examples/serialize.rs new file mode 100644 index 000000000..9b50c0f6e --- /dev/null +++ b/browser/vendor/xmloxide/examples/serialize.rs @@ -0,0 +1,39 @@ +//! XML serialization and roundtrip example. +//! +//! Run with: `cargo run --example serialize` +#![allow(clippy::expect_used)] + +use xmloxide::serial::serialize; +use xmloxide::Document; + +fn main() { + let xml = r#"<?xml version="1.0" encoding="UTF-8"?> +<root xmlns:app="http://example.com/app"> + <app:config version="2.0"> + <app:setting name="debug">true</app:setting> + <app:setting name="timeout">30</app:setting> + </app:config> + <data> + <item id="1">First &amp; foremost</item> + <item id="2">Less &lt;than&gt; more</item> + <![CDATA[Some <raw> content & stuff]]> + </data> +</root>"#; + + println!("=== Original XML ==="); + println!("{xml}"); + + // Parse + let doc = Document::parse_str(xml).expect("failed to parse"); + + // Serialize + let output = serialize(&doc); + println!("\n=== Serialized ==="); + println!("{output}"); + + // Roundtrip: parse the serialized output again + let doc2 = Document::parse_str(&output).expect("roundtrip parse failed"); + let output2 = serialize(&doc2); + + println!("\n=== Roundtrip stable: {} ===", output == output2); +} diff --git a/browser/vendor/xmloxide/examples/validation.rs b/browser/vendor/xmloxide/examples/validation.rs new file mode 100644 index 000000000..7de9605a5 --- /dev/null +++ b/browser/vendor/xmloxide/examples/validation.rs @@ -0,0 +1,112 @@ +//! DTD, `RelaxNG`, and XSD validation examples. +//! +//! xmloxide supports validating XML documents against DTD, `RelaxNG`, and +//! XML Schema (XSD) schemas. +//! +//! Run with: `cargo run --example validation` +#![allow(clippy::expect_used)] + +use xmloxide::validation::dtd::{parse_dtd, validate}; +use xmloxide::validation::relaxng::{parse_relaxng, validate as validate_rng}; +use xmloxide::validation::xsd::{parse_xsd, validate_xsd}; +use xmloxide::Document; + +fn main() { + dtd_example(); + relaxng_example(); + xsd_example(); +} + +fn dtd_example() { + println!("=== DTD Validation ===\n"); + + let dtd_str = r" + <!ELEMENT catalog (book+)> + <!ELEMENT book (title, author)> + <!ELEMENT title (#PCDATA)> + <!ELEMENT author (#PCDATA)> + <!ATTLIST book id ID #REQUIRED> + "; + + // Valid document + let valid_xml = r#"<catalog> + <book id="b1"><title>Rust Programming</title><author>Alice</author></book> + <book id="b2"><title>XML Essentials</title><author>Bob</author></book> + </catalog>"#; + + let dtd = parse_dtd(dtd_str).expect("DTD parse failed"); + let mut doc = Document::parse_str(valid_xml).expect("XML parse failed"); + let result = validate(&mut doc, &dtd); + println!("Valid document: is_valid={}", result.is_valid); + + // Invalid document (missing required element) + let invalid_xml = r#"<catalog> + <book id="b1"><title>No Author</title></book> + </catalog>"#; + + let mut doc = Document::parse_str(invalid_xml).expect("XML parse failed"); + let result = validate(&mut doc, &dtd); + println!("Invalid document: is_valid={}", result.is_valid); + for err in &result.errors { + println!(" Error: {err}"); + } + println!(); +} + +fn relaxng_example() { + println!("=== RelaxNG Validation ===\n"); + + let schema_xml = r#"<element name="person" xmlns="http://relaxng.org/ns/structure/1.0"> + <element name="name"><text/></element> + <element name="email"><text/></element> + </element>"#; + + let valid_xml = "<person><name>Alice</name><email>alice@example.com</email></person>"; + let invalid_xml = "<person><name>Bob</name></person>"; + + let schema = parse_relaxng(schema_xml).expect("RelaxNG parse failed"); + + let doc = Document::parse_str(valid_xml).expect("XML parse failed"); + let result = validate_rng(&doc, &schema); + println!("Valid document: is_valid={}", result.is_valid); + + let doc = Document::parse_str(invalid_xml).expect("XML parse failed"); + let result = validate_rng(&doc, &schema); + println!("Invalid document: is_valid={}", result.is_valid); + for err in &result.errors { + println!(" Error: {err}"); + } + println!(); +} + +fn xsd_example() { + println!("=== XSD Validation ===\n"); + + let schema_xml = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="temperature"> + <xs:complexType> + <xs:simpleContent> + <xs:extension base="xs:decimal"> + <xs:attribute name="unit" type="xs:string" use="required"/> + </xs:extension> + </xs:simpleContent> + </xs:complexType> + </xs:element> + </xs:schema>"#; + + let valid_xml = r#"<temperature unit="celsius">36.6</temperature>"#; + let invalid_xml = r#"<temperature unit="celsius">not-a-number</temperature>"#; + + let schema = parse_xsd(schema_xml).expect("XSD parse failed"); + + let doc = Document::parse_str(valid_xml).expect("XML parse failed"); + let result = validate_xsd(&doc, &schema); + println!("Valid document: is_valid={}", result.is_valid); + + let doc = Document::parse_str(invalid_xml).expect("XML parse failed"); + let result = validate_xsd(&doc, &schema); + println!("Invalid document: is_valid={}", result.is_valid); + for err in &result.errors { + println!(" Error: {err}"); + } +} diff --git a/browser/vendor/xmloxide/examples/xinclude.rs b/browser/vendor/xmloxide/examples/xinclude.rs new file mode 100644 index 000000000..e3964c896 --- /dev/null +++ b/browser/vendor/xmloxide/examples/xinclude.rs @@ -0,0 +1,47 @@ +//! `XInclude` document inclusion example. +//! +//! `XInclude` allows XML documents to include content from other sources +//! via `xi:include` elements. xmloxide processes these inclusions using +//! a resolver callback that you provide. +//! +//! Run with: `cargo run --example xinclude` +#![allow(clippy::expect_used)] + +use xmloxide::serial::serialize; +use xmloxide::xinclude::{process_xincludes, XIncludeOptions}; +use xmloxide::Document; + +fn main() { + // Main document with xi:include elements + let main_xml = r#"<?xml version="1.0"?> +<manual xmlns:xi="http://www.w3.org/2001/XInclude"> + <title>User Guide</title> + <xi:include href="chapter1.xml"/> + <xi:include href="chapter2.xml"/> + <xi:include href="missing.xml"> + <xi:fallback><section><title>Coming Soon</title></section></xi:fallback> + </xi:include> +</manual>"#; + + // Simulated external files + let chapter1 = "<chapter><title>Getting Started</title><p>Welcome to xmloxide.</p></chapter>"; + let chapter2 = + "<chapter><title>Advanced Usage</title><p>XPath, validation, and more.</p></chapter>"; + + let mut doc = Document::parse_str(main_xml).expect("parse failed"); + + // Process XIncludes with a resolver that returns file content + let result = process_xincludes( + &mut doc, + |href| match href { + "chapter1.xml" => Some(chapter1.to_string()), + "chapter2.xml" => Some(chapter2.to_string()), + _ => None, // missing.xml will use the fallback + }, + &XIncludeOptions::default(), + ); + + println!("Inclusions processed: {}", result.inclusions); + println!("Errors: {}", result.errors.len()); + println!("\nResult:\n{}", serialize(&doc)); +} diff --git a/browser/vendor/xmloxide/examples/xpath_query.rs b/browser/vendor/xmloxide/examples/xpath_query.rs new file mode 100644 index 000000000..8f44fb298 --- /dev/null +++ b/browser/vendor/xmloxide/examples/xpath_query.rs @@ -0,0 +1,61 @@ +//! `XPath` query examples. +//! +//! Run with: `cargo run --example xpath_query` +#![allow(clippy::expect_used)] + +use xmloxide::xpath::{evaluate, XPathValue}; +use xmloxide::Document; + +fn main() { + let xml = r#"<?xml version="1.0"?> +<library> + <book genre="fiction" id="1"> + <title>The Great Gatsby</title> + <author>F. Scott Fitzgerald</author> + <price>10.99</price> + </book> + <book genre="science" id="2"> + <title>A Brief History of Time</title> + <author>Stephen Hawking</author> + <price>14.99</price> + </book> + <book genre="fiction" id="3"> + <title>1984</title> + <author>George Orwell</author> + <price>8.99</price> + </book> +</library>"#; + + let doc = Document::parse_str(xml).expect("failed to parse XML"); + let root = doc.root_element().expect("no root element"); + + // Count all books + let result = evaluate(&doc, root, "count(book)").expect("XPath failed"); + println!("Total books: {}", result.to_number()); + + // Find all fiction books + println!("\nFiction books:"); + let result = evaluate(&doc, root, "book[@genre='fiction']/title").expect("XPath failed"); + if let XPathValue::NodeSet(nodes) = &result { + for &node in nodes { + println!(" - {}", doc.text_content(node.anchor())); + } + } + + // Find books over $10 + println!("\nBooks over $10:"); + let result = evaluate(&doc, root, "book[number(price) > 10]/title").expect("XPath failed"); + if let XPathValue::NodeSet(nodes) = &result { + for &node in nodes { + println!(" - {}", doc.text_content(node.anchor())); + } + } + + // Get a string value + let result = evaluate(&doc, root, "string(book[@id='2']/author)").expect("XPath failed"); + println!("\nAuthor of book 2: {}", result.to_xpath_string()); + + // Sum prices + let result = evaluate(&doc, root, "sum(book/price)").expect("XPath failed"); + println!("Total price: ${:.2}", result.to_number()); +} diff --git a/browser/vendor/xmloxide/include/libxml2_compat.h b/browser/vendor/xmloxide/include/libxml2_compat.h new file mode 100644 index 000000000..dc044f0a4 --- /dev/null +++ b/browser/vendor/xmloxide/include/libxml2_compat.h @@ -0,0 +1,316 @@ +/* + * libxml2_compat.h — libxml2-like API adaptor for xmloxide + * + * This header provides a thin compatibility layer that maps common libxml2 + * function names and types to xmloxide's C FFI. It covers the most frequently + * used libxml2 APIs (parsing, tree navigation, serialization, XPath) to ease + * migration from libxml2 to xmloxide. + * + * Usage: + * #include "libxml2_compat.h" + * // Use familiar libxml2 names — they delegate to xmloxide + * + * Limitations: + * - Node pointers are NOT dereferenceable structs. You cannot write + * node->name or node->children. Use the accessor functions instead. + * - No global state: xmlInitParser() and xmlCleanupParser() are no-ops. + * - No custom error handlers (xmlSetGenericErrorFunc is a no-op). + * - Only covers commonly-used APIs. See xmloxide.h for the full API. + * + * Requires: xmloxide.h (include it first or let this header include it). + */ + +#ifndef LIBXML2_COMPAT_H +#define LIBXML2_COMPAT_H + +#include "xmloxide.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ======================================================================== + * Type aliases + * ======================================================================== */ + +/** Opaque document type (replaces libxml2's xmlDoc / xmlDocPtr). */ +typedef xmloxide_document xmlDoc; +typedef xmloxide_document *xmlDocPtr; + +/** + * Node handle — NOT a dereferenceable pointer like libxml2's xmlNode. + * + * In xmloxide, nodes are identified by a (document, node_id) pair. + * This struct wraps both so you can pass "node pointers" around. + * Access node properties via xmlNodeGetName(), xmlNodeGetContent(), etc. + */ +typedef struct { + xmloxide_document *doc; + uint32_t id; +} xmlNode; +typedef xmlNode *xmlNodePtr; + +/** XPath result type. */ +typedef xmloxide_xpath_value xmlXPathObject; +typedef xmloxide_xpath_value *xmlXPathObjectPtr; + +/* ======================================================================== + * Global lifecycle — no-ops (xmloxide has no global state) + * ======================================================================== */ + +/** No-op. xmloxide requires no global initialization. */ +static inline void xmlInitParser(void) {} + +/** No-op. xmloxide requires no global cleanup. */ +static inline void xmlCleanupParser(void) {} + +/** No-op. xmloxide has no global memory tracking. */ +static inline void xmlMemoryDump(void) {} + +/* ======================================================================== + * Parsing + * ======================================================================== */ + +/** Parse a null-terminated XML string. Returns NULL on failure. */ +static inline xmlDocPtr xmlParseDoc(const char *input) { + return xmloxide_parse_str(input); +} + +/** Parse a buffer of `size` bytes as XML. Returns NULL on failure. */ +static inline xmlDocPtr xmlReadMemory(const char *buffer, int size, + const char *url, const char *encoding, + int options) { + (void)url; (void)encoding; (void)options; + return xmloxide_parse_bytes((const uint8_t *)buffer, (size_t)size); +} + +/** Parse an XML file. Returns NULL on failure. */ +static inline xmlDocPtr xmlReadFile(const char *filename, const char *encoding, + int options) { + (void)encoding; (void)options; + return xmloxide_parse_file(filename); +} + +/** Parse an HTML string. Returns NULL on failure. */ +static inline xmlDocPtr htmlReadMemory(const char *buffer, int size, + const char *url, const char *encoding, + int options) { + (void)size; (void)url; (void)encoding; (void)options; + return xmloxide_parse_html(buffer); +} + +/** Parse an HTML5 string. Returns NULL on failure. */ +static inline xmlDocPtr htmlReadMemory5(const char *buffer, int size, + const char *url, const char *encoding, + int options) { + (void)size; (void)url; (void)encoding; (void)options; + return xmloxide_parse_html5(buffer); +} + +/** Free a document. */ +static inline void xmlFreeDoc(xmlDocPtr doc) { + xmloxide_free_doc(doc); +} + +/* ======================================================================== + * Tree navigation — returns heap-allocated xmlNode (caller must free) + * ======================================================================== */ + +/** Allocate an xmlNode handle. Caller must free with xmlFreeNode(). */ +static inline xmlNodePtr xmloxide_compat_make_node(xmlDocPtr doc, uint32_t id) { + if (id == 0) return NULL; + xmlNodePtr node = (xmlNodePtr)malloc(sizeof(xmlNode)); + if (node) { + node->doc = doc; + node->id = id; + } + return node; +} + +/** Free an xmlNode handle. Does NOT remove the node from the tree. */ +static inline void xmlFreeNode(xmlNodePtr node) { + free(node); +} + +/** Get the root element. Caller must free the returned node with xmlFreeNode(). */ +static inline xmlNodePtr xmlDocGetRootElement(xmlDocPtr doc) { + return xmloxide_compat_make_node(doc, xmloxide_doc_root_element(doc)); +} + +/** Get the parent node. Caller must free with xmlFreeNode(). */ +static inline xmlNodePtr xmlNodeGetParent(xmlNodePtr node) { + if (!node) return NULL; + return xmloxide_compat_make_node(node->doc, + xmloxide_node_parent(node->doc, node->id)); +} + +/** Get the first child. Caller must free with xmlFreeNode(). */ +static inline xmlNodePtr xmlNodeGetChildren(xmlNodePtr node) { + if (!node) return NULL; + return xmloxide_compat_make_node(node->doc, + xmloxide_node_first_child(node->doc, node->id)); +} + +/** Get the next sibling. Caller must free with xmlFreeNode(). */ +static inline xmlNodePtr xmlNodeGetNext(xmlNodePtr node) { + if (!node) return NULL; + return xmloxide_compat_make_node(node->doc, + xmloxide_node_next_sibling(node->doc, node->id)); +} + +/** Get the previous sibling. Caller must free with xmlFreeNode(). */ +static inline xmlNodePtr xmlNodeGetPrev(xmlNodePtr node) { + if (!node) return NULL; + return xmloxide_compat_make_node(node->doc, + xmloxide_node_prev_sibling(node->doc, node->id)); +} + +/* ======================================================================== + * Node inspection + * ======================================================================== */ + +/** Get the node type (returns XMLOXIDE_NODE_* constants). */ +static inline int xmlNodeGetType(xmlNodePtr node) { + if (!node) return -1; + return xmloxide_node_type(node->doc, node->id); +} + +/** + * Get the node name. Returns a caller-owned string that must be freed + * with xmlFree(). + */ +static inline char *xmlNodeGetName(xmlNodePtr node) { + if (!node) return NULL; + return xmloxide_node_name(node->doc, node->id); +} + +/** + * Get the concatenated text content. Returns a caller-owned string + * that must be freed with xmlFree(). + */ +static inline char *xmlNodeGetContent(xmlNodePtr node) { + if (!node) return NULL; + return xmloxide_node_text_content(node->doc, node->id); +} + +/** + * Get an attribute value by name. Returns a caller-owned string + * that must be freed with xmlFree(). + */ +static inline char *xmlGetProp(xmlNodePtr node, const char *name) { + if (!node) return NULL; + return xmloxide_node_attribute(node->doc, node->id, name); +} + +/** + * Set an attribute value. Returns 1 on success, 0 on failure. + */ +static inline int xmlSetProp(xmlNodePtr node, const char *name, + const char *value) { + if (!node) return 0; + return xmloxide_set_attribute(node->doc, node->id, name, value); +} + +/** + * Remove an attribute by name. Returns 1 if removed, 0 otherwise. + */ +static inline int xmlUnsetProp(xmlNodePtr node, const char *name) { + if (!node) return 0; + return xmloxide_remove_attribute(node->doc, node->id, name); +} + +/* ======================================================================== + * Serialization + * ======================================================================== */ + +/** + * Serialize a document to XML. The caller must free the result with xmlFree(). + * `mem` receives the string pointer, `size` receives the length. + */ +static inline void xmlDocDumpMemory(xmlDocPtr doc, char **mem, int *size) { + if (!doc || !mem) return; + char *s = xmloxide_serialize(doc); + *mem = s; + if (size) *size = s ? (int)strlen(s) : 0; +} + +/** + * Serialize a document to pretty-printed XML. The caller must free the + * result with xmlFree(). `mem` receives the string pointer, `size` the length. + */ +static inline void xmlDocDumpFormatMemory(xmlDocPtr doc, char **mem, + int *size, int format) { + (void)format; + if (!doc || !mem) return; + char *s = xmloxide_serialize_pretty(doc); + *mem = s; + if (size) *size = s ? (int)strlen(s) : 0; +} + +/* ======================================================================== + * String lifecycle + * ======================================================================== */ + +/** + * Free a string returned by xmloxide (replaces libxml2's xmlFree for strings). + */ +static inline void xmlFree(void *ptr) { + xmloxide_free_string((char *)ptr); +} + +/* ======================================================================== + * XPath + * ======================================================================== */ + +/** + * Evaluate an XPath expression. Returns NULL on failure. + * The result must be freed with xmlXPathFreeObject(). + */ +static inline xmlXPathObjectPtr xmlXPathEval(const char *expr, + xmlNodePtr context) { + if (!context) return NULL; + return xmloxide_xpath_eval(context->doc, context->id, expr); +} + +/** Free an XPath result. */ +static inline void xmlXPathFreeObject(xmlXPathObjectPtr obj) { + xmloxide_xpath_free_result(obj); +} + +/** Get the number of nodes in an XPath nodeset result. */ +static inline int xmlXPathNodeSetGetLength(xmlXPathObjectPtr obj) { + return (int)xmloxide_xpath_nodeset_count(obj); +} + +/** + * Get a node from an XPath nodeset by index. + * NOTE: Unlike libxml2, this requires the original document pointer. + * The returned node must be freed with xmlFreeNode(). + */ +static inline xmlNodePtr xmlXPathNodeSetItem(xmlXPathObjectPtr obj, + int index, + xmlDocPtr doc) { + uint32_t id = xmloxide_xpath_nodeset_item(obj, (size_t)index); + return xmloxide_compat_make_node(doc, id); +} + +/* ======================================================================== + * Error handling + * ======================================================================== */ + +/** Get the last error message. Library-owned — do NOT free. */ +static inline const char *xmlGetLastError(void) { + return xmloxide_last_error(); +} + +/** No-op. xmloxide uses thread-local error storage, not callbacks. */ +static inline void xmlSetGenericErrorFunc(void *ctx, + void (*handler)(void *, const char *, ...)) { + (void)ctx; (void)handler; +} + +#ifdef __cplusplus +} +#endif + +#endif /* LIBXML2_COMPAT_H */ diff --git a/browser/vendor/xmloxide/include/xmloxide.h b/browser/vendor/xmloxide/include/xmloxide.h new file mode 100644 index 000000000..478cca230 --- /dev/null +++ b/browser/vendor/xmloxide/include/xmloxide.h @@ -0,0 +1,1007 @@ +/* + * xmloxide.h — C API for xmloxide + * + * A memory-safe XML parsing library implemented in Rust. + * + * All returned strings are caller-owned and must be freed with + * xmloxide_free_string(). Document and XPath result pointers must be + * freed with their respective free functions. + * + * Error handling: functions that can fail return NULL (for pointers) + * or 0 (for node ids). Call xmloxide_last_error() to retrieve the + * error message for the most recent failure on the current thread. + * + * Thread safety: Unlike libxml2, xmloxide requires no global + * initialization or cleanup. Each document is independent and may be + * used from any thread. The last-error message is stored in thread-local + * storage, so each thread has its own error state. A single document + * must not be accessed concurrently from multiple threads without + * external synchronization. + */ + +#ifndef XMLOXIDE_H +#define XMLOXIDE_H + +#include <stddef.h> +#include <stdint.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---------- Opaque types ---------- */ + +/** Opaque XML document handle. */ +typedef struct xmloxide_document xmloxide_document; + +/** Opaque XPath result handle. */ +typedef struct xmloxide_xpath_value xmloxide_xpath_value; + +/** Opaque DTD handle. */ +typedef struct xmloxide_dtd xmloxide_dtd; + +/** Opaque RelaxNG schema handle. */ +typedef struct xmloxide_relaxng_schema xmloxide_relaxng_schema; + +/** Opaque XSD schema handle. */ +typedef struct xmloxide_xsd_schema xmloxide_xsd_schema; + +/** Opaque Schematron schema handle. */ +typedef struct xmloxide_schematron_schema xmloxide_schematron_schema; + +/** Opaque validation result handle. */ +typedef struct xmloxide_validation_result xmloxide_validation_result; + +/** Opaque XML Catalog handle. */ +typedef struct xmloxide_catalog xmloxide_catalog; + +/** Opaque push parser handle. */ +typedef struct xmloxide_push_parser xmloxide_push_parser; + +/** Opaque XML reader handle. */ +typedef struct xmloxide_reader xmloxide_reader; + +/* ---------- Node type constants ---------- */ + +#define XMLOXIDE_NODE_ELEMENT 1 +#define XMLOXIDE_NODE_TEXT 3 +#define XMLOXIDE_NODE_CDATA 4 +#define XMLOXIDE_NODE_ENTITY_REF 5 +#define XMLOXIDE_NODE_PI 7 +#define XMLOXIDE_NODE_COMMENT 8 +#define XMLOXIDE_NODE_DOCUMENT 9 +#define XMLOXIDE_NODE_DOCUMENT_TYPE 10 + +/* ---------- XPath result type constants ---------- */ + +#define XMLOXIDE_XPATH_NODESET 1 +#define XMLOXIDE_XPATH_BOOLEAN 2 +#define XMLOXIDE_XPATH_NUMBER 3 +#define XMLOXIDE_XPATH_STRING 4 + +/* ---------- Error severity constants ---------- */ + +#define XMLOXIDE_ERR_WARNING 0 +#define XMLOXIDE_ERR_ERROR 1 +#define XMLOXIDE_ERR_FATAL 2 + +/* ---------- Error handling ---------- */ + +/** + * Returns the last error message, or NULL if no error occurred. + * + * The returned string is owned by the library and must NOT be freed. + * It is valid until the next xmloxide FFI call on the same thread. + */ +const char *xmloxide_last_error(void); + +/** + * Returns the line number where the last error occurred, or 0 if unknown. + */ +uint32_t xmloxide_last_error_line(void); + +/** + * Returns the column number where the last error occurred, or 0 if unknown. + */ +uint32_t xmloxide_last_error_column(void); + +/** + * Returns the severity of the last error. + * Returns XMLOXIDE_ERR_WARNING (0), XMLOXIDE_ERR_ERROR (1), + * or XMLOXIDE_ERR_FATAL (2). Returns -1 if no error occurred. + */ +int32_t xmloxide_last_error_severity(void); + +/* ---------- Document lifecycle ---------- */ + +/** + * Parses a null-terminated UTF-8 XML string into a document. + * + * Returns a document pointer on success, or NULL on failure. + * The returned document must be freed with xmloxide_free_doc(). + */ +xmloxide_document *xmloxide_parse_str(const char *input); + +/** + * Parses raw bytes as XML, with automatic encoding detection. + * + * Returns a document pointer on success, or NULL on failure. + * The returned document must be freed with xmloxide_free_doc(). + */ +xmloxide_document *xmloxide_parse_bytes(const uint8_t *data, size_t len); + +/** + * Parses an HTML string into a document. + * + * Returns a document pointer on success, or NULL on failure. + * The returned document must be freed with xmloxide_free_doc(). + */ +xmloxide_document *xmloxide_parse_html(const char *input); + +/** + * Parses an HTML5 string using the WHATWG parsing algorithm. + * + * Returns a document pointer on success, or NULL on failure. + * The returned document must be freed with xmloxide_free_doc(). + */ +xmloxide_document *xmloxide_parse_html5(const char *input); + +/** + * Parses an HTML5 fragment with a context element (the innerHTML algorithm). + * + * context_element is the tag name of the context (e.g., "body", "div", "table"). + * Returns a document pointer on success, or NULL on failure. + * The returned document must be freed with xmloxide_free_doc(). + */ +xmloxide_document *xmloxide_parse_html5_fragment(const char *input, + const char *context_element); + +/** + * Parses an XML file from a filesystem path. + * + * Returns a document pointer on success, or NULL on failure. + * The returned document must be freed with xmloxide_free_doc(). + */ +xmloxide_document *xmloxide_parse_file(const char *path); + +/** + * Frees a document previously returned by a parse function. + * Passing NULL is safe and does nothing. + */ +void xmloxide_free_doc(xmloxide_document *doc); + +/* ---------- Document properties ---------- */ + +/** + * Returns the XML version string (e.g., "1.0"), or NULL if not declared. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_doc_version(const xmloxide_document *doc); + +/** + * Returns the encoding string (e.g., "UTF-8"), or NULL if not declared. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_doc_encoding(const xmloxide_document *doc); + +/* ---------- Document diagnostics ---------- */ + +/** + * Returns the number of parse diagnostics (warnings + recovered errors) + * on a document. Returns 0 if the document has no diagnostics. + */ +size_t xmloxide_doc_diagnostic_count(const xmloxide_document *doc); + +/** + * Returns the error message of the diagnostic at the given index. + * Returns NULL if out of range. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_doc_diagnostic_message(const xmloxide_document *doc, size_t index); + +/** Returns the line number of the diagnostic at the given index (0 if unknown). */ +uint32_t xmloxide_doc_diagnostic_line(const xmloxide_document *doc, size_t index); + +/** Returns the column number of the diagnostic at the given index (0 if unknown). */ +uint32_t xmloxide_doc_diagnostic_column(const xmloxide_document *doc, size_t index); + +/** + * Returns the severity of the diagnostic at the given index. + * Returns XMLOXIDE_ERR_WARNING, XMLOXIDE_ERR_ERROR, or XMLOXIDE_ERR_FATAL. + * Returns -1 if out of range. + */ +int32_t xmloxide_doc_diagnostic_severity(const xmloxide_document *doc, size_t index); + +/* ---------- Tree navigation ---------- */ + +/* + * Node IDs are uint32_t values. A value of 0 means "no node" + * (invalid/missing). + */ + +/** Returns the document root node id. */ +uint32_t xmloxide_doc_root(const xmloxide_document *doc); + +/** Returns the root element of the document, or 0 if none. */ +uint32_t xmloxide_doc_root_element(const xmloxide_document *doc); + +/** Returns the parent of a node, or 0 if none. */ +uint32_t xmloxide_node_parent(const xmloxide_document *doc, uint32_t node); + +/** Returns the first child of a node, or 0 if none. */ +uint32_t xmloxide_node_first_child(const xmloxide_document *doc, uint32_t node); + +/** Returns the last child of a node, or 0 if none. */ +uint32_t xmloxide_node_last_child(const xmloxide_document *doc, uint32_t node); + +/** Returns the next sibling of a node, or 0 if none. */ +uint32_t xmloxide_node_next_sibling(const xmloxide_document *doc, uint32_t node); + +/** Returns the previous sibling of a node, or 0 if none. */ +uint32_t xmloxide_node_prev_sibling(const xmloxide_document *doc, uint32_t node); + +/* ---------- Node inspection ---------- */ + +/** + * Returns the node type as an integer constant. + * Returns -1 if the document or node is invalid. + */ +int32_t xmloxide_node_type(const xmloxide_document *doc, uint32_t node); + +/** + * Returns the name of a node (element local name or PI target). + * Returns NULL for node types that have no name. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_node_name(const xmloxide_document *doc, uint32_t node); + +/** + * Returns the direct text content of a text, comment, CDATA, or PI node. + * Returns NULL for element and document nodes. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_node_text(const xmloxide_document *doc, uint32_t node); + +/** + * Returns the concatenated text content of a node and all descendants. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_node_text_content(const xmloxide_document *doc, uint32_t node); + +/** + * Returns the namespace URI of an element node, or NULL if none. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_node_namespace(const xmloxide_document *doc, uint32_t node); + +/** + * Returns the namespace prefix of an element node (e.g., "svg" for <svg:rect>). + * Returns NULL if no prefix. The returned string must be freed with + * xmloxide_free_string(). + */ +char *xmloxide_node_prefix(const xmloxide_document *doc, uint32_t node); + +/** + * Returns the value of an attribute by name on an element node. + * Returns NULL if the attribute is not present. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_node_attribute(const xmloxide_document *doc, uint32_t node, + const char *name); + +/** + * Returns the number of attributes on an element node. + * Returns 0 for non-element nodes. + */ +size_t xmloxide_node_attribute_count(const xmloxide_document *doc, uint32_t node); + +/** + * Returns the name of the attribute at the given index. + * Returns NULL if the index is out of range. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_node_attribute_name_at(const xmloxide_document *doc, + uint32_t node, size_t index); + +/** + * Returns the value of the attribute at the given index. + * Returns NULL if the index is out of range. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_node_attribute_value_at(const xmloxide_document *doc, + uint32_t node, size_t index); + +/* ---------- Tree mutation ---------- */ + +/** + * Creates a new element node and returns its id (0 on failure). + * The node is detached — use xmloxide_append_child() to add it to the tree. + */ +uint32_t xmloxide_create_element(xmloxide_document *doc, const char *name); + +/** + * Creates a new text node and returns its id (0 on failure). + */ +uint32_t xmloxide_create_text(xmloxide_document *doc, const char *content); + +/** + * Creates a new comment node and returns its id (0 on failure). + */ +uint32_t xmloxide_create_comment(xmloxide_document *doc, const char *content); + +/** + * Appends a child node to a parent. Returns 1 on success, 0 on failure. + */ +int32_t xmloxide_append_child(xmloxide_document *doc, uint32_t parent, + uint32_t child); + +/** + * Removes a node from the tree. Returns 1 on success, 0 on failure. + * The node remains in the arena but is detached from the tree. + */ +int32_t xmloxide_remove_node(xmloxide_document *doc, uint32_t node); + +/** + * Clones a node (and optionally its descendants). Returns the new node id. + * Set deep=1 for a deep clone, deep=0 for a shallow clone. + * Returns 0 on failure. + */ +uint32_t xmloxide_clone_node(xmloxide_document *doc, uint32_t node, int32_t deep); + +/** + * Sets an attribute on an element node. Returns 1 on success, 0 on failure. + * If the attribute already exists, its value is updated. + */ +int32_t xmloxide_set_attribute(xmloxide_document *doc, uint32_t node, + const char *name, const char *value); + +/** + * Sets the text content of a node. Returns 1 on success, 0 on failure. + * For text/CDATA/comment nodes, updates content directly. + * For element nodes, removes all children and replaces with a text node. + */ +int32_t xmloxide_set_text_content(xmloxide_document *doc, uint32_t node, + const char *content); + +/** + * Inserts a node before a reference sibling. Returns 1 on success, 0 on failure. + */ +int32_t xmloxide_insert_before(xmloxide_document *doc, uint32_t reference, + uint32_t new_child); + +/** + * Inserts a node after a reference sibling. Returns 1 on success, 0 on failure. + */ +int32_t xmloxide_insert_after(xmloxide_document *doc, uint32_t reference, + uint32_t new_child); + +/** + * Replaces a node in the tree with another. Returns 1 on success, 0 on failure. + * The old node is detached and the new node takes its position. + */ +int32_t xmloxide_replace_node(xmloxide_document *doc, uint32_t old_node, + uint32_t new_node); + +/** + * Removes an attribute by name from an element node. + * Returns 1 if removed, 0 if not found or not an element. + */ +int32_t xmloxide_remove_attribute(xmloxide_document *doc, uint32_t node, + const char *name); + +/** + * Creates a new processing instruction node and returns its id (0 on failure). + * data may be NULL. + */ +uint32_t xmloxide_create_pi(xmloxide_document *doc, const char *target, + const char *data); + +/** + * Renames an element node. Returns 1 on success, 0 on failure. + */ +int32_t xmloxide_rename_element(xmloxide_document *doc, uint32_t node, + const char *new_name); + +/** + * Returns the element with the given ID attribute, or 0 if not found. + * The document's id_map must be populated first (typically via DTD validation). + */ +uint32_t xmloxide_element_by_id(const xmloxide_document *doc, const char *id); + +/* ---------- Serialization ---------- */ + +/** + * Serializes a document to an XML string. + * Returns a caller-owned C string that must be freed with + * xmloxide_free_string(). Returns NULL on failure. + */ +char *xmloxide_serialize(const xmloxide_document *doc); + +/** + * Serializes a document to a pretty-printed XML string with two-space indent. + * Returns a caller-owned C string that must be freed with + * xmloxide_free_string(). Returns NULL on failure. + */ +char *xmloxide_serialize_pretty(const xmloxide_document *doc); + +/** + * Serializes a document to a pretty-printed XML string with a custom indent. + * indent_str is the string used for each level (e.g., "\t" or " "). + * Returns a caller-owned C string that must be freed with + * xmloxide_free_string(). Returns NULL on failure. + */ +char *xmloxide_serialize_pretty_custom(const xmloxide_document *doc, + const char *indent_str); + +/** + * Serializes a document to an HTML string. + * Returns a caller-owned C string that must be freed with + * xmloxide_free_string(). Returns NULL on failure. + */ +char *xmloxide_serialize_html(const xmloxide_document *doc); + +/** + * Serializes a document to an HTML5 string (WHATWG algorithm). + * Returns a caller-owned C string that must be freed with + * xmloxide_free_string(). Returns NULL on failure. + */ +char *xmloxide_serialize_html5(const xmloxide_document *doc); + +/* ---------- Validation ---------- */ + +/** + * Parses a DTD from a null-terminated UTF-8 string. + * Returns a DTD pointer on success, or NULL on failure. + * The returned DTD must be freed with xmloxide_free_dtd(). + */ +xmloxide_dtd *xmloxide_parse_dtd(const char *input); + +/** Frees a DTD. Passing NULL is safe and does nothing. */ +void xmloxide_free_dtd(xmloxide_dtd *dtd); + +/** + * Validates a document against a DTD. + * Note: DTD validation may populate the document's id_map (requires mutable doc). + * Returns a validation result that must be freed with + * xmloxide_free_validation_result(). + */ +xmloxide_validation_result *xmloxide_validate_dtd(xmloxide_document *doc, + const xmloxide_dtd *dtd); + +/** + * Parses a RelaxNG schema from a null-terminated UTF-8 XML string. + * Returns a schema pointer on success, or NULL on failure. + * The returned schema must be freed with xmloxide_free_relaxng(). + */ +xmloxide_relaxng_schema *xmloxide_parse_relaxng(const char *input); + +/** Frees a RelaxNG schema. Passing NULL is safe and does nothing. */ +void xmloxide_free_relaxng(xmloxide_relaxng_schema *schema); + +/** + * Validates a document against a RelaxNG schema. + * Returns a validation result that must be freed with + * xmloxide_free_validation_result(). + */ +xmloxide_validation_result *xmloxide_validate_relaxng(const xmloxide_document *doc, + const xmloxide_relaxng_schema *schema); + +/** + * Parses an XSD schema from a null-terminated UTF-8 XML string. + * Returns a schema pointer on success, or NULL on failure. + * The returned schema must be freed with xmloxide_free_xsd(). + */ +xmloxide_xsd_schema *xmloxide_parse_xsd(const char *input); + +/** Frees an XSD schema. Passing NULL is safe and does nothing. */ +void xmloxide_free_xsd(xmloxide_xsd_schema *schema); + +/** + * Validates a document against an XSD schema. + * Returns a validation result that must be freed with + * xmloxide_free_validation_result(). + */ +xmloxide_validation_result *xmloxide_validate_xsd(const xmloxide_document *doc, + const xmloxide_xsd_schema *schema); + +/** + * Parses an ISO Schematron schema from a null-terminated UTF-8 XML string. + * Returns a schema pointer on success, or NULL on failure. + * The returned schema must be freed with xmloxide_free_schematron(). + */ +xmloxide_schematron_schema *xmloxide_parse_schematron(const char *input); + +/** Frees a Schematron schema. Passing NULL is safe and does nothing. */ +void xmloxide_free_schematron(xmloxide_schematron_schema *schema); + +/** + * Validates a document against an ISO Schematron schema. + * Returns a validation result that must be freed with + * xmloxide_free_validation_result(). + */ +xmloxide_validation_result *xmloxide_validate_schematron( + const xmloxide_document *doc, + const xmloxide_schematron_schema *schema); + +/** + * Validates a document against a Schematron schema using a specific phase. + * phase is the name of the phase to activate (NULL for all patterns). + * Returns a validation result that must be freed with + * xmloxide_free_validation_result(). + */ +xmloxide_validation_result *xmloxide_validate_schematron_with_phase( + const xmloxide_document *doc, + const xmloxide_schematron_schema *schema, + const char *phase); + +/** + * Returns whether the validation result indicates a valid document. + * Returns 1 for valid, 0 for invalid or NULL. + */ +int32_t xmloxide_validation_is_valid(const xmloxide_validation_result *result); + +/** + * Returns the number of validation errors. + * Returns 0 if the result is NULL. + */ +size_t xmloxide_validation_error_count(const xmloxide_validation_result *result); + +/** + * Returns the error message at the given index. + * Returns NULL if the index is out of range. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_validation_error_message(const xmloxide_validation_result *result, + size_t index); + +/** + * Returns the number of validation warnings. + * Returns 0 if the result is NULL. + */ +size_t xmloxide_validation_warning_count(const xmloxide_validation_result *result); + +/** + * Returns the warning message at the given index. + * Returns NULL if the index is out of range. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_validation_warning_message(const xmloxide_validation_result *result, + size_t index); + +/** + * Frees a validation result. Passing NULL is safe and does nothing. + */ +void xmloxide_free_validation_result(xmloxide_validation_result *result); + +/* ---------- XPath ---------- */ + +/** + * Evaluates an XPath expression against a context node. + * + * Returns a pointer to the result on success, or NULL on failure. + * Use context_node=0 to use the document root as context. + * The returned result must be freed with xmloxide_xpath_free_result(). + */ +xmloxide_xpath_value *xmloxide_xpath_eval(const xmloxide_document *doc, + uint32_t context_node, + const char *expr); + +/** + * Returns the type of an XPath result. + * Returns one of the XMLOXIDE_XPATH_* constants, or -1 on error. + */ +int32_t xmloxide_xpath_result_type(const xmloxide_xpath_value *result); + +/** + * Returns the boolean value of an XPath result. + * Converts non-boolean results using XPath type coercion rules. + */ +int32_t xmloxide_xpath_result_boolean(const xmloxide_xpath_value *result); + +/** + * Returns the numeric value of an XPath result. + * Converts non-number results using XPath type coercion rules. + */ +double xmloxide_xpath_result_number(const xmloxide_xpath_value *result); + +/** + * Returns the string value of an XPath result. + * Converts non-string results using XPath type coercion rules. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_xpath_result_string(const xmloxide_xpath_value *result); + +/** Returns the number of nodes in an XPath nodeset result. */ +size_t xmloxide_xpath_nodeset_count(const xmloxide_xpath_value *result); + +/** + * Returns the node id at the given index in an XPath nodeset result. + * For attribute nodes, returns the id of the owner element (use the + * item_is_attribute / item_attr_name / item_attr_value accessors to + * inspect the attribute itself). + * Returns 0 if the result is not a nodeset or the index is out of bounds. + */ +uint32_t xmloxide_xpath_nodeset_item(const xmloxide_xpath_value *result, + size_t index); + +/** + * Returns 1 if the nodeset entry at the given index is an attribute node, + * 0 otherwise (including out-of-bounds and non-nodeset results). + */ +int xmloxide_xpath_nodeset_item_is_attribute(const xmloxide_xpath_value *result, + size_t index); + +/** + * Returns the qualified name (prefix:local) of the attribute at the given + * index in a nodeset result, or NULL if the entry is not an attribute. + * `doc` must be the document the result was evaluated against. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_xpath_nodeset_item_attr_name(const xmloxide_document *doc, + const xmloxide_xpath_value *result, + size_t index); + +/** + * Returns the value of the attribute at the given index in a nodeset + * result, or NULL if the entry is not an attribute. + * `doc` must be the document the result was evaluated against. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_xpath_nodeset_item_attr_value(const xmloxide_document *doc, + const xmloxide_xpath_value *result, + size_t index); + +/** + * Frees an XPath result previously returned by xmloxide_xpath_eval(). + * Passing NULL is safe and does nothing. + */ +void xmloxide_xpath_free_result(xmloxide_xpath_value *result); + +/* ---------- Canonical XML (C14N) ---------- */ + +/** + * Canonicalizes a document using inclusive C14N with comments. + * Returns a caller-owned C string that must be freed with + * xmloxide_free_string(). Returns NULL on failure. + */ +char *xmloxide_canonicalize(const xmloxide_document *doc); + +/** + * Canonicalizes a document with options. + * with_comments: 1 to include comments, 0 to strip. + * exclusive: 1 for exclusive C14N, 0 for inclusive. + */ +char *xmloxide_canonicalize_opts(const xmloxide_document *doc, + int32_t with_comments, int32_t exclusive); + +/** + * Canonicalizes a subtree rooted at the given node. + */ +char *xmloxide_canonicalize_subtree(const xmloxide_document *doc, + uint32_t node, int32_t with_comments, + int32_t exclusive); + +/* ---------- XInclude ---------- */ + +/** + * Processes XInclude elements in a document using file-based resolution. + * Returns the number of successful inclusions, or -1 on failure. + * Errors are stored in the thread-local error (retrievable via xmloxide_last_error). + */ +int32_t xmloxide_process_xincludes(xmloxide_document *doc); + +/* ---------- XML Catalogs ---------- */ + +/** + * Parses an XML Catalog from a null-terminated UTF-8 XML string. + * Returns a catalog pointer on success, or NULL on failure. + * The returned catalog must be freed with xmloxide_free_catalog(). + */ +xmloxide_catalog *xmloxide_parse_catalog(const char *input); + +/** Frees a catalog. Passing NULL is safe and does nothing. */ +void xmloxide_free_catalog(xmloxide_catalog *catalog); + +/** + * Resolves a system identifier using the catalog. + * Returns a caller-owned URI string, or NULL if not found. + */ +char *xmloxide_catalog_resolve_system(const xmloxide_catalog *catalog, + const char *system_id); + +/** + * Resolves a public identifier using the catalog. + * Returns a caller-owned URI string, or NULL if not found. + */ +char *xmloxide_catalog_resolve_public(const xmloxide_catalog *catalog, + const char *public_id); + +/** + * Resolves a URI using the catalog. + * Returns a caller-owned URI string, or NULL if not found. + */ +char *xmloxide_catalog_resolve_uri(const xmloxide_catalog *catalog, + const char *uri); + +/* ---------- Push parser (incremental) ---------- */ + +/** + * Creates a new push parser with default options. + * The returned parser must be consumed via xmloxide_push_parser_finish() + * or freed with xmloxide_push_parser_free(). + */ +xmloxide_push_parser *xmloxide_push_parser_new(void); + +/** + * Feeds a chunk of raw bytes into the push parser. + * Data can be split at arbitrary byte boundaries. + */ +void xmloxide_push_parser_push(xmloxide_push_parser *parser, + const uint8_t *data, size_t len); + +/** + * Finalizes parsing and returns the constructed document. + * + * This CONSUMES the parser — the parser pointer becomes invalid after + * this call. Do NOT call xmloxide_push_parser_free() after finish. + * + * Returns a document pointer on success, or NULL on failure. + * The returned document must be freed with xmloxide_free_doc(). + */ +xmloxide_document *xmloxide_push_parser_finish(xmloxide_push_parser *parser); + +/** + * Returns the number of bytes currently buffered in the push parser. + */ +size_t xmloxide_push_parser_buffered_bytes(const xmloxide_push_parser *parser); + +/** + * Resets the push parser, discarding all buffered data. + * The parser can then be reused for a new document. + */ +void xmloxide_push_parser_reset(xmloxide_push_parser *parser); + +/** + * Frees a push parser without finishing it. + * Use this to discard a parser whose data you no longer need. + * Passing NULL is safe. Do NOT call after finish(). + */ +void xmloxide_push_parser_free(xmloxide_push_parser *parser); + +/* ---------- XmlReader (pull-based streaming) ---------- */ + +/* + * Reader node type constants (matching libxml2's xmlReaderTypes). + */ +#define XMLOXIDE_READER_NONE 0 +#define XMLOXIDE_READER_ELEMENT 1 +#define XMLOXIDE_READER_ATTRIBUTE 2 +#define XMLOXIDE_READER_TEXT 3 +#define XMLOXIDE_READER_CDATA 4 +#define XMLOXIDE_READER_PI 7 +#define XMLOXIDE_READER_COMMENT 8 +#define XMLOXIDE_READER_DOCUMENT_TYPE 10 +#define XMLOXIDE_READER_WHITESPACE 13 +#define XMLOXIDE_READER_END_ELEMENT 15 +#define XMLOXIDE_READER_XML_DECLARATION 17 +#define XMLOXIDE_READER_END_DOCUMENT (-1) + +/** + * Creates a new XmlReader from a null-terminated UTF-8 string. + * Returns an opaque reader pointer, or NULL on failure. + * The reader must be freed with xmloxide_reader_free(). + */ +xmloxide_reader *xmloxide_reader_new(const char *input); + +/** + * Advances the reader to the next node. + * Returns 1 if a node was read, 0 at end of document, -1 on error. + */ +int32_t xmloxide_reader_read(xmloxide_reader *reader); + +/** + * Returns the node type of the current node. + * Returns one of the XMLOXIDE_READER_* constants. + */ +int32_t xmloxide_reader_node_type(const xmloxide_reader *reader); + +/** + * Returns the qualified name of the current node, or NULL. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_reader_name(const xmloxide_reader *reader); + +/** + * Returns the local name of the current node (without prefix), or NULL. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_reader_local_name(const xmloxide_reader *reader); + +/** + * Returns the namespace prefix of the current node, or NULL. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_reader_prefix(const xmloxide_reader *reader); + +/** + * Returns the namespace URI of the current node, or NULL. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_reader_namespace_uri(const xmloxide_reader *reader); + +/** + * Returns the value of the current node (text, comment, attribute value), + * or NULL for elements and end elements. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_reader_value(const xmloxide_reader *reader); + +/** Returns the depth of the current node in the document tree. */ +uint32_t xmloxide_reader_depth(const xmloxide_reader *reader); + +/** + * Returns 1 if the current element is self-closing (empty), 0 otherwise. + */ +int32_t xmloxide_reader_is_empty_element(const xmloxide_reader *reader); + +/** + * Returns 1 if the current node has a value, 0 otherwise. + */ +int32_t xmloxide_reader_has_value(const xmloxide_reader *reader); + +/** Returns the number of attributes on the current element. */ +size_t xmloxide_reader_attribute_count(const xmloxide_reader *reader); + +/** + * Returns the value of an attribute by name on the current element, or NULL. + * The returned string must be freed with xmloxide_free_string(). + */ +char *xmloxide_reader_get_attribute(const xmloxide_reader *reader, + const char *name); + +/** + * Moves the reader to the first attribute of the current element. + * Returns 1 if successful, 0 if no attributes or not on an element. + */ +int32_t xmloxide_reader_move_to_first_attribute(xmloxide_reader *reader); + +/** + * Moves the reader to the next attribute. + * Returns 1 if successful, 0 if no more attributes. + */ +int32_t xmloxide_reader_move_to_next_attribute(xmloxide_reader *reader); + +/** + * Moves the reader back to the element from an attribute. + * Returns 1 if moved back, 0 if not on an attribute. + */ +int32_t xmloxide_reader_move_to_element(xmloxide_reader *reader); + +/** + * Frees a reader. Passing NULL is safe and does nothing. + */ +void xmloxide_reader_free(xmloxide_reader *reader); + +/* ---------- SAX2 streaming parser ---------- */ + +/** + * C function pointer type for start_element events. + * + * Parameters: + * local_name - element local name (never NULL) + * prefix - namespace prefix (may be NULL) + * namespace - namespace URI (may be NULL) + * attr_names - array of attribute name strings + * attr_values - array of attribute value strings + * attr_count - number of attributes + * user_data - opaque pointer passed through from the handler + */ +typedef void (*xmloxide_sax_start_element_cb)( + const char *local_name, const char *prefix, const char *namespace_uri, + const char *const *attr_names, const char *const *attr_values, + size_t attr_count, void *user_data); + +/** + * C function pointer type for end_element events. + * + * Parameters: + * local_name - element local name (never NULL) + * prefix - namespace prefix (may be NULL) + * namespace - namespace URI (may be NULL) + * user_data - opaque pointer passed through from the handler + */ +typedef void (*xmloxide_sax_end_element_cb)(const char *local_name, + const char *prefix, + const char *namespace_uri, + void *user_data); + +/** + * C function pointer type for characters, CDATA, and comment events. + * + * Parameters: + * content - text content (never NULL) + * user_data - opaque pointer passed through from the handler + */ +typedef void (*xmloxide_sax_text_cb)(const char *content, void *user_data); + +/** + * C function pointer type for processing instruction events. + * + * Parameters: + * target - PI target (never NULL) + * data - PI data (may be NULL) + * user_data - opaque pointer passed through from the handler + */ +typedef void (*xmloxide_sax_pi_cb)(const char *target, const char *data, + void *user_data); + +/** + * SAX handler with C function pointer callbacks. + * + * Set any callback to NULL to ignore that event type. + * user_data is passed through to every callback. + */ +typedef struct { + xmloxide_sax_start_element_cb start_element; + xmloxide_sax_end_element_cb end_element; + xmloxide_sax_text_cb characters; + xmloxide_sax_text_cb cdata; + xmloxide_sax_text_cb comment; + xmloxide_sax_pi_cb processing_instruction; + void *user_data; +} xmloxide_sax_handler; + +/** + * Parses XML with SAX streaming, dispatching events to C function pointers. + * + * xml must be a valid null-terminated UTF-8 C string. + * handler must point to a valid xmloxide_sax_handler struct. + * + * Returns 0 on success, -1 on error. Use xmloxide_last_error() for details. + */ +int32_t xmloxide_sax_parse(const char *xml, + const xmloxide_sax_handler *handler); + +/* ---------- CSS selectors ---------- */ + +/** + * Evaluates a CSS selector against a subtree and returns matching node IDs. + * + * scope is the node to search within (typically the root element). + * selector is a null-terminated CSS selector string (e.g., "div.class > p"). + * + * On success, sets *out_count to the number of matching nodes and returns + * a heap-allocated array of node IDs. The caller must free the array with + * xmloxide_free_nodeid_array(ptr, count). + * + * Returns NULL on failure (invalid selector or null arguments). + */ +uint32_t *xmloxide_css_select(const xmloxide_document *doc, uint32_t scope, + const char *selector, size_t *out_count); + +/** + * Frees a node ID array returned by xmloxide_css_select(). + * Passing NULL is safe and does nothing. + */ +void xmloxide_free_nodeid_array(uint32_t *ptr, size_t count); + +/** + * Returns the first node matching a CSS selector, or 0 if none found. + * This is a convenience wrapper around xmloxide_css_select(). + */ +uint32_t xmloxide_css_select_first(const xmloxide_document *doc, uint32_t scope, + const char *selector); + +/* ---------- String lifecycle ---------- */ + +/** + * Frees a string previously returned by an xmloxide FFI function. + * Passing NULL is safe and does nothing. + */ +void xmloxide_free_string(char *ptr); + +#ifdef __cplusplus +} +#endif + +#endif /* XMLOXIDE_H */ diff --git a/browser/vendor/xmloxide/src/async_xml.rs b/browser/vendor/xmloxide/src/async_xml.rs new file mode 100644 index 000000000..29028811d --- /dev/null +++ b/browser/vendor/xmloxide/src/async_xml.rs @@ -0,0 +1,146 @@ +//! Async XML parsing via `tokio::io::AsyncRead`. +//! +//! This module provides [`parse_async`], which reads from any `AsyncRead` +//! source and builds a [`Document`] using the push parser internally. +//! +//! Requires the `async` feature. +//! +//! # Examples +//! +//! ```no_run +//! # #[cfg(feature = "async")] +//! # async fn example() -> Result<(), Box<dyn std::error::Error>> { +//! use xmloxide::async_xml::parse_async; +//! +//! let file = tokio::fs::File::open("data.xml").await?; +//! let doc = parse_async(file).await?; +//! let root = doc.root_element().unwrap(); +//! println!("Root: {:?}", doc.node_name(root)); +//! # Ok(()) +//! # } +//! ``` + +use tokio::io::{AsyncRead, AsyncReadExt}; + +use crate::error::ParseError; +use crate::parser::{ParseOptions, PushParser}; +use crate::tree::Document; + +/// Default buffer size for async reads (8 KiB). +const DEFAULT_BUF_SIZE: usize = 8192; + +/// Parses XML from an `AsyncRead` source using default options. +/// +/// Reads the source in chunks and feeds them to the push parser. +/// +/// # Errors +/// +/// Returns a `ParseError` if the XML is malformed. +pub async fn parse_async<R: AsyncRead + Unpin>(reader: R) -> Result<Document, ParseError> { + parse_async_with_options(reader, ParseOptions::default()).await +} + +/// Parses XML from an `AsyncRead` source with the given parse options. +/// +/// # Errors +/// +/// Returns a `ParseError` if the XML is malformed. +pub async fn parse_async_with_options<R: AsyncRead + Unpin>( + mut reader: R, + options: ParseOptions, +) -> Result<Document, ParseError> { + let mut parser = PushParser::with_options(options); + let mut buf = vec![0u8; DEFAULT_BUF_SIZE]; + + loop { + let n = reader.read(&mut buf).await.map_err(|e| ParseError { + message: format!("I/O error: {e}"), + location: crate::error::SourceLocation { + line: 0, + column: 0, + byte_offset: 0, + }, + diagnostics: vec![], + })?; + if n == 0 { + break; + } + parser.push(&buf[..n]); + } + + parser.finish() +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_parse_async_from_bytes() { + let data = b"<root><child>Hello</child></root>"; + let cursor = std::io::Cursor::new(data); + let doc = parse_async(cursor).await.unwrap(); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_name(root), Some("root")); + assert_eq!(doc.text_content(root), "Hello"); + } + + #[tokio::test] + async fn test_parse_async_empty_document() { + let data = b"<root/>"; + let cursor = std::io::Cursor::new(data); + let doc = parse_async(cursor).await.unwrap(); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_name(root), Some("root")); + } + + #[tokio::test] + async fn test_parse_async_with_options() { + let data = b"<root><child>text</child></root>"; + let cursor = std::io::Cursor::new(data); + let opts = ParseOptions::default().recover(true); + let doc = parse_async_with_options(cursor, opts).await.unwrap(); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_name(root), Some("root")); + } + + #[tokio::test] + async fn test_parse_async_malformed() { + let data = b"<root><</root>"; + let cursor = std::io::Cursor::new(data); + let result = parse_async(cursor).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_parse_async_small_reads() { + // Simulate a reader that yields one byte at a time + let data = b"<root>Hello</root>"; + let cursor = SlowReader { data, pos: 0 }; + let doc = parse_async(cursor).await.unwrap(); + let root = doc.root_element().unwrap(); + assert_eq!(doc.text_content(root), "Hello"); + } + + /// Test reader that yields one byte at a time. + struct SlowReader { + data: &'static [u8], + pos: usize, + } + + impl AsyncRead for SlowReader { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll<std::io::Result<()>> { + if self.pos >= self.data.len() { + return std::task::Poll::Ready(Ok(())); + } + buf.put_slice(&self.data[self.pos..=self.pos]); + self.pos += 1; + std::task::Poll::Ready(Ok(())) + } + } +} diff --git a/browser/vendor/xmloxide/src/bin/xmllint.rs b/browser/vendor/xmloxide/src/bin/xmllint.rs new file mode 100644 index 000000000..2e1f71e6b --- /dev/null +++ b/browser/vendor/xmloxide/src/bin/xmllint.rs @@ -0,0 +1,923 @@ +//! xmllint-compatible CLI tool for XML/HTML processing. +//! +//! Provides the most commonly used features of libxml2's `xmllint` command: +//! parsing, validation, `XPath` evaluation, canonical XML output, and more. + +use std::fmt::Write as _; +use std::fs; +use std::io::{self, Read, Write}; +use std::process::ExitCode; +use std::time::Instant; + +use clap::Parser; + +use xmloxide::html::parse_html_with_options; +use xmloxide::html5::parse_html5; +use xmloxide::parser::{self, ParseOptions}; +use xmloxide::serial::c14n::{canonicalize, C14nOptions}; +use xmloxide::serial::serialize; +use xmloxide::tree::{Document, NodeId, NodeKind}; +use xmloxide::xpath; + +// --------------------------------------------------------------------------- +// CLI argument definitions +// --------------------------------------------------------------------------- + +/// xmllint -- parse, validate, and process XML/HTML files. +/// +/// A Rust reimplementation of libxml2's xmllint, powered by xmloxide. +#[derive(Parser, Debug)] +#[command(name = "xmllint", version, about, long_about = None)] +#[allow(clippy::struct_excessive_bools)] +struct Cli { + /// XML files to process (use `-` for stdin). + #[arg(required = true)] + files: Vec<String>, + + /// Print additional information during processing. + #[arg(long)] + verbose: bool, + + // -- Parsing options --------------------------------------------------- + /// Parse input as HTML 4.01 instead of XML. + #[arg(long)] + html: bool, + + /// Parse input as HTML5 (WHATWG) instead of XML. + #[arg(long)] + html5: bool, + + /// Recover from parsing errors (produce partial tree). + #[arg(long)] + recover: bool, + + /// Remove ignorable blank (whitespace-only) text nodes. + #[arg(long)] + noblanks: bool, + + /// Do not output the result tree. + #[arg(long)] + noout: bool, + + /// Output in the given encoding (e.g., UTF-8, ISO-8859-1). + #[arg(long, value_name = "ENCODING")] + encode: Option<String>, + + // -- Validation options ------------------------------------------------ + /// Validate against the DTD declared in the document. + #[arg(long)] + valid: bool, + + /// Validate against an external DTD file. + #[arg(long, value_name = "FILE")] + dtdvalid: Option<String>, + + /// Validate against a RelaxNG schema file. + #[allow(clippy::doc_markdown)] + #[arg(long, value_name = "FILE")] + relaxng: Option<String>, + + /// Validate against an XML Schema (XSD) file. + #[arg(long, value_name = "FILE")] + schema: Option<String>, + + /// Validate against an ISO Schematron schema file. + #[arg(long, value_name = "FILE")] + schematron: Option<String>, + + // -- XPath ------------------------------------------------------------- + /// Evaluate an XPath expression and print the result. + #[allow(clippy::doc_markdown)] + #[arg(long, value_name = "EXPR")] + xpath: Option<String>, + + // -- Output options ---------------------------------------------------- + /// Pretty-print (indent) the output. + #[arg(long)] + format: bool, + + /// Canonical XML (C14N 1.0) output. + #[arg(long)] + c14n: bool, + + /// Exclusive Canonical XML output. + #[arg(long = "exc-c14n")] + exc_c14n: bool, + + /// Save output to a file instead of stdout. + #[arg(long, value_name = "FILE")] + output: Option<String>, + + // -- Debug options ----------------------------------------------------- + /// Print a debug representation of the document tree. + #[arg(long)] + debug: bool, + + /// Print timing information for parsing and processing. + #[arg(long)] + timing: bool, +} + +// --------------------------------------------------------------------------- +// Exit codes (matching libxml2 xmllint conventions) +// --------------------------------------------------------------------------- + +const EXIT_SUCCESS: u8 = 0; +const EXIT_PARSE_ERROR: u8 = 1; +const EXIT_VALIDATION_ERROR: u8 = 3; + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +fn main() -> ExitCode { + let cli = Cli::parse(); + let mut worst_exit: u8 = EXIT_SUCCESS; + + for file in &cli.files { + let exit = process_file(&cli, file); + if exit > worst_exit { + worst_exit = exit; + } + } + + ExitCode::from(worst_exit) +} + +/// Processes a single input file and returns an exit code. +fn process_file(cli: &Cli, filename: &str) -> u8 { + // -- Read input -------------------------------------------------------- + let start_read = Instant::now(); + + let input = match read_input(filename) { + Ok(data) => data, + Err(e) => { + eprintln!("{filename}: failed to read: {e}"); + return EXIT_PARSE_ERROR; + } + }; + + if cli.timing { + let elapsed = start_read.elapsed(); + eprintln!("Reading file {filename} took {elapsed:?}"); + } + + // -- Parse ------------------------------------------------------------- + let start_parse = Instant::now(); + + let doc = if cli.html5 { + parse_as_html5(&input) + } else if cli.html { + parse_as_html(cli, &input) + } else { + parse_as_xml(cli, &input) + }; + + let mut doc = match doc { + Ok(d) => d, + Err(msg) => { + eprintln!("{filename}: {msg}"); + return EXIT_PARSE_ERROR; + } + }; + + if cli.timing { + let elapsed = start_parse.elapsed(); + eprintln!("Parsing took {elapsed:?}"); + } + + if cli.verbose && !doc.diagnostics.is_empty() { + for diag in &doc.diagnostics { + eprintln!("{filename}: {diag}"); + } + } + + // -- Validation -------------------------------------------------------- + let mut exit_code = EXIT_SUCCESS; + + if cli.valid { + let code = validate_dtd_internal(filename, &mut doc); + if code > exit_code { + exit_code = code; + } + } + + if let Some(ref dtd_file) = cli.dtdvalid { + let code = validate_dtd_external(filename, &mut doc, dtd_file); + if code > exit_code { + exit_code = code; + } + } + + if let Some(ref rng_file) = cli.relaxng { + let code = validate_relaxng_file(filename, &doc, rng_file); + if code > exit_code { + exit_code = code; + } + } + + if let Some(ref xsd_file) = cli.schema { + let code = validate_xsd_file(filename, &doc, xsd_file); + if code > exit_code { + exit_code = code; + } + } + + if let Some(ref sch_file) = cli.schematron { + let code = validate_schematron_file(filename, &doc, sch_file); + if code > exit_code { + exit_code = code; + } + } + + // -- XPath evaluation -------------------------------------------------- + if let Some(ref expr) = cli.xpath { + evaluate_xpath(filename, &doc, expr); + } + + // -- Debug tree -------------------------------------------------------- + if cli.debug { + let debug_output = format_debug_tree(&doc); + write_output(cli, &debug_output); + return exit_code; + } + + // -- Serialization / output -------------------------------------------- + if !cli.noout && cli.xpath.is_none() { + let start_serial = Instant::now(); + + let output_str = serialize_document(cli, &doc); + write_output(cli, &output_str); + + if cli.timing { + let elapsed = start_serial.elapsed(); + eprintln!("Serializing took {elapsed:?}"); + } + } + + exit_code +} + +// --------------------------------------------------------------------------- +// Input reading +// --------------------------------------------------------------------------- + +/// Reads input from a file or stdin (when filename is `-`). +fn read_input(filename: &str) -> io::Result<String> { + if filename == "-" { + let mut buf = String::new(); + io::stdin().read_to_string(&mut buf)?; + Ok(buf) + } else { + fs::read_to_string(filename) + } +} + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +/// Parses input as XML with the configured options. +fn parse_as_xml(cli: &Cli, input: &str) -> Result<Document, String> { + let opts = ParseOptions::default() + .recover(cli.recover) + .no_blanks(cli.noblanks); + parser::parse_str_with_options(input, &opts).map_err(|e| e.to_string()) +} + +/// Parses input as HTML 4.01 with the configured options. +fn parse_as_html(cli: &Cli, input: &str) -> Result<Document, String> { + let opts = xmloxide::html::HtmlParseOptions::default() + .recover(cli.recover) + .no_blanks(cli.noblanks); + parse_html_with_options(input, &opts).map_err(|e| e.to_string()) +} + +/// Parses input as HTML5 (WHATWG parsing algorithm). +fn parse_as_html5(input: &str) -> Result<Document, String> { + parse_html5(input).map_err(|e| e.to_string()) +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/// Validates a document against its internal DTD (--valid). +fn validate_dtd_internal(filename: &str, doc: &mut Document) -> u8 { + let dtd_text = extract_internal_dtd_subset(doc); + if dtd_text.is_empty() { + eprintln!("{filename}: no DTD found for validation"); + return EXIT_VALIDATION_ERROR; + } + + match xmloxide::validation::dtd::parse_dtd(&dtd_text) { + Ok(dtd) => { + let result = xmloxide::validation::dtd::validate(doc, &dtd); + print_validation_result(filename, &result) + } + Err(e) => { + eprintln!("{filename}: failed to parse DTD: {e}"); + EXIT_VALIDATION_ERROR + } + } +} + +/// Validates a document against an external DTD file (--dtdvalid). +fn validate_dtd_external(filename: &str, doc: &mut Document, dtd_file: &str) -> u8 { + let dtd_content = match fs::read_to_string(dtd_file) { + Ok(content) => content, + Err(e) => { + eprintln!("{dtd_file}: failed to read DTD: {e}"); + return EXIT_VALIDATION_ERROR; + } + }; + + match xmloxide::validation::dtd::parse_dtd(&dtd_content) { + Ok(dtd) => { + let result = xmloxide::validation::dtd::validate(doc, &dtd); + print_validation_result(filename, &result) + } + Err(e) => { + eprintln!("{dtd_file}: failed to parse DTD: {e}"); + EXIT_VALIDATION_ERROR + } + } +} + +/// Validates a document against a `RelaxNG` schema file (--relaxng). +fn validate_relaxng_file(filename: &str, doc: &Document, rng_file: &str) -> u8 { + let schema_content = match fs::read_to_string(rng_file) { + Ok(content) => content, + Err(e) => { + eprintln!("{rng_file}: failed to read RelaxNG schema: {e}"); + return EXIT_VALIDATION_ERROR; + } + }; + + match xmloxide::validation::relaxng::parse_relaxng(&schema_content) { + Ok(schema) => { + let result = xmloxide::validation::relaxng::validate(doc, &schema); + print_validation_result(filename, &result) + } + Err(e) => { + eprintln!("{rng_file}: failed to parse RelaxNG schema: {e}"); + EXIT_VALIDATION_ERROR + } + } +} + +/// Validates a document against an XML Schema (XSD) file (--schema). +fn validate_xsd_file(filename: &str, doc: &Document, xsd_file: &str) -> u8 { + let schema_content = match fs::read_to_string(xsd_file) { + Ok(content) => content, + Err(e) => { + eprintln!("{xsd_file}: failed to read XML Schema: {e}"); + return EXIT_VALIDATION_ERROR; + } + }; + + match xmloxide::validation::xsd::parse_xsd(&schema_content) { + Ok(schema) => { + let result = xmloxide::validation::xsd::validate_xsd(doc, &schema); + print_validation_result(filename, &result) + } + Err(e) => { + eprintln!("{xsd_file}: failed to parse XML Schema: {e}"); + EXIT_VALIDATION_ERROR + } + } +} + +/// Validates a document against an ISO Schematron schema file (--schematron). +fn validate_schematron_file(filename: &str, doc: &Document, sch_file: &str) -> u8 { + let schema_content = match fs::read_to_string(sch_file) { + Ok(content) => content, + Err(e) => { + eprintln!("{sch_file}: failed to read Schematron schema: {e}"); + return EXIT_VALIDATION_ERROR; + } + }; + + match xmloxide::validation::schematron::parse_schematron(&schema_content) { + Ok(schema) => { + let result = xmloxide::validation::schematron::validate_schematron(doc, &schema); + print_validation_result(filename, &result) + } + Err(e) => { + eprintln!("{sch_file}: failed to parse Schematron schema: {e}"); + EXIT_VALIDATION_ERROR + } + } +} + +/// Prints validation errors/warnings and returns the exit code. +fn print_validation_result(filename: &str, result: &xmloxide::validation::ValidationResult) -> u8 { + for warning in &result.warnings { + eprintln!("{filename}: validity warning: {warning}"); + } + for error in &result.errors { + eprintln!("{filename}: validity error: {error}"); + } + if result.is_valid { + eprintln!("{filename} validates"); + EXIT_SUCCESS + } else { + eprintln!("{filename} fails to validate"); + EXIT_VALIDATION_ERROR + } +} + +// --------------------------------------------------------------------------- +// XPath evaluation +// --------------------------------------------------------------------------- + +/// Evaluates an `XPath` expression and prints the result to stdout. +fn evaluate_xpath(filename: &str, doc: &Document, expression: &str) { + let context_node = doc.root_element().unwrap_or_else(|| doc.root()); + + match xpath::evaluate(doc, context_node, expression) { + Ok(value) => match &value { + xpath::XPathValue::NodeSet(nodes) => { + for &node in nodes { + match node { + xpath::XPathNode::Node(node_id) => { + let content = serialize_subtree(doc, node_id); + println!("{content}"); + } + xpath::XPathNode::Attribute { owner, index } => { + // Print attribute nodes as name="value" with the + // value XML-escaped, matching libxml2's xmllint. + if let Some(attr) = doc.attributes(owner).get(index as usize) { + let value = escape_attribute_value(&attr.value); + match &attr.prefix { + Some(prefix) => { + println!("{prefix}:{}=\"{value}\"", attr.name); + } + None => println!("{}=\"{value}\"", attr.name), + } + } + } + } + } + } + xpath::XPathValue::String(s) => { + println!("{s}"); + } + xpath::XPathValue::Number(n) => { + println!("{n}"); + } + xpath::XPathValue::Boolean(b) => { + println!("{b}"); + } + }, + Err(e) => { + eprintln!("{filename}: XPath error: {e}"); + } + } +} + +/// Serializes a single node and its subtree to XML. +/// Escapes an attribute value for `name="value"` output (XML 1.0 §2.3). +fn escape_attribute_value(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '&' => out.push_str("&amp;"), + '<' => out.push_str("&lt;"), + '>' => out.push_str("&gt;"), + '"' => out.push_str("&quot;"), + _ => out.push(ch), + } + } + out +} + +fn serialize_subtree(doc: &Document, node_id: NodeId) -> String { + let mut output = String::new(); + serialize_node_recursive(doc, node_id, &mut output); + output +} + +/// Recursively serializes a node to a string (for `XPath` output). +fn serialize_node_recursive(doc: &Document, id: NodeId, out: &mut String) { + match &doc.node(id).kind { + NodeKind::Element { + name, + prefix, + attributes, + .. + } => { + out.push('<'); + if let Some(pfx) = prefix { + out.push_str(pfx); + out.push(':'); + } + out.push_str(name); + for attr in attributes { + out.push(' '); + if let Some(pfx) = &attr.prefix { + out.push_str(pfx); + out.push(':'); + } + out.push_str(&attr.name); + out.push_str("=\""); + out.push_str(&attr.value); + out.push('"'); + } + if doc.first_child(id).is_none() { + out.push_str("/>"); + } else { + out.push('>'); + for child in doc.children(id) { + serialize_node_recursive(doc, child, out); + } + out.push_str("</"); + if let Some(pfx) = prefix { + out.push_str(pfx); + out.push(':'); + } + out.push_str(name); + out.push('>'); + } + } + NodeKind::Text { content } => { + out.push_str(content); + } + NodeKind::CData { content } => { + out.push_str("<![CDATA["); + out.push_str(content); + out.push_str("]]>"); + } + NodeKind::Comment { content } => { + out.push_str("<!--"); + out.push_str(content); + out.push_str("-->"); + } + NodeKind::ProcessingInstruction { target, data } => { + out.push_str("<?"); + out.push_str(target); + if let Some(d) = data { + out.push(' '); + out.push_str(d); + } + out.push_str("?>"); + } + NodeKind::EntityRef { name, .. } => { + out.push('&'); + out.push_str(name); + out.push(';'); + } + NodeKind::DocumentType { .. } | NodeKind::Document => {} + } +} + +// --------------------------------------------------------------------------- +// Serialization +// --------------------------------------------------------------------------- + +/// Serializes a document to string using the configured output mode. +fn serialize_document(cli: &Cli, doc: &Document) -> String { + if cli.c14n || cli.exc_c14n { + let opts = C14nOptions { + with_comments: true, + exclusive: cli.exc_c14n, + inclusive_prefixes: Vec::new(), + }; + let mut result = canonicalize(doc, &opts); + result.push('\n'); + result + } else { + let mut output = serialize(doc); + if cli.format { + output = pretty_print(&output); + } + if let Some(ref enc) = cli.encode { + output = update_encoding_declaration(&output, enc); + } + if !output.ends_with('\n') { + output.push('\n'); + } + output + } +} + +// --------------------------------------------------------------------------- +// Pretty printing +// --------------------------------------------------------------------------- + +/// Simple pretty-printer that adds newlines and indentation between tags. +/// +/// This operates on the serialized XML string, inserting newlines and +/// indentation at tag boundaries. It handles: +/// - Newlines after the XML declaration +/// - Indentation of nested elements +/// - Preserving text content inline with its parent element +fn pretty_print(xml: &str) -> String { + let mut result = String::with_capacity(xml.len() * 2); + let mut indent_level: usize = 0; + let indent_str = " "; + + // Split the XML into tokens: tags (starting with '<' and ending with '>') + // and text content between tags. + let tokens = tokenize_xml(xml); + + let mut i = 0; + while i < tokens.len() { + let token = &tokens[i]; + + if token.starts_with("<?") { + // Processing instruction / XML declaration + result.push_str(token); + result.push('\n'); + } else if token.starts_with("<!--") { + // Comment + push_indent(&mut result, indent_level, indent_str); + result.push_str(token); + result.push('\n'); + } else if token.starts_with("<!") { + // DOCTYPE or other declaration + push_indent(&mut result, indent_level, indent_str); + result.push_str(token); + result.push('\n'); + } else if token.starts_with("</") { + // Closing tag + indent_level = indent_level.saturating_sub(1); + push_indent(&mut result, indent_level, indent_str); + result.push_str(token); + result.push('\n'); + } else if token.starts_with('<') { + let extra = format_open_tag(&tokens, i, &mut result, &mut indent_level, indent_str); + i += extra; + } else { + // Text content on its own + let trimmed = token.trim(); + if !trimmed.is_empty() { + push_indent(&mut result, indent_level, indent_str); + result.push_str(trimmed); + result.push('\n'); + } + } + + i += 1; + } + + result +} + +/// Splits XML into tokens of tags and text content. +fn tokenize_xml(xml: &str) -> Vec<String> { + let mut tokens: Vec<String> = Vec::new(); + let mut current = String::new(); + + for ch in xml.chars() { + if ch == '<' { + if !current.is_empty() { + tokens.push(current.clone()); + current.clear(); + } + current.push(ch); + } else if ch == '>' { + current.push(ch); + tokens.push(current.clone()); + current.clear(); + } else { + current.push(ch); + } + } + if !current.is_empty() { + tokens.push(current); + } + + tokens +} + +/// Handles formatting of an opening tag token, including inline text +/// optimization where `<tag>text</tag>` stays on one line. +/// +/// Returns the number of extra tokens consumed (for the caller to skip). +fn format_open_tag( + tokens: &[String], + i: usize, + result: &mut String, + indent_level: &mut usize, + indent_str: &str, +) -> usize { + let token = &tokens[i]; + let is_self_closing = token.ends_with("/>"); + + // Check if the next token is text content (not another tag) + let next_is_text = tokens.get(i + 1).is_some_and(|t| !t.starts_with('<')); + + // Check if it's like <tag>text</tag> (inline text content) + let is_inline_text = next_is_text && tokens.get(i + 2).is_some_and(|t| t.starts_with("</")); + + if is_self_closing { + push_indent(result, *indent_level, indent_str); + result.push_str(token); + result.push('\n'); + 0 + } else if is_inline_text { + // Output <tag>text</tag> on one line + push_indent(result, *indent_level, indent_str); + result.push_str(token); + result.push_str(&tokens[i + 1]); // text + result.push_str(&tokens[i + 2]); // </tag> + result.push('\n'); + 2 // skip text + closing tag + } else { + push_indent(result, *indent_level, indent_str); + result.push_str(token); + if next_is_text { + result.push_str(&tokens[i + 1]); + *indent_level += 1; + 1 // skip the text token + } else { + result.push('\n'); + *indent_level += 1; + 0 + } + } +} + +/// Writes indentation to the output string. +fn push_indent(out: &mut String, level: usize, indent: &str) { + for _ in 0..level { + out.push_str(indent); + } +} + +// --------------------------------------------------------------------------- +// Encoding +// --------------------------------------------------------------------------- + +/// Updates the encoding attribute in an XML declaration, if present. +fn update_encoding_declaration(xml: &str, new_encoding: &str) -> String { + if let Some(decl_end) = xml.find("?>") { + let decl = &xml[..decl_end]; + if let Some(enc_start) = decl.find("encoding=\"") { + let after_enc = &decl[enc_start + 10..]; + if let Some(enc_end) = after_enc.find('"') { + let mut result = String::with_capacity(xml.len()); + result.push_str(&xml[..enc_start + 10]); + result.push_str(new_encoding); + result.push_str(&xml[enc_start + 10 + enc_end..]); + return result; + } + } + } + xml.to_string() +} + +// --------------------------------------------------------------------------- +// Debug tree +// --------------------------------------------------------------------------- + +/// Produces a textual debug representation of the document tree. +/// +/// The format resembles libxml2's `--debug` output: each node is printed +/// with its type and content, indented to show the tree structure. +fn format_debug_tree(doc: &Document) -> String { + let mut output = String::new(); + output.push_str("DOCUMENT\n"); + for child in doc.children(doc.root()) { + format_debug_node(doc, child, 1, &mut output); + } + output +} + +/// Recursively formats a node for debug output. +fn format_debug_node(doc: &Document, id: NodeId, depth: usize, out: &mut String) { + let indent: String = " ".repeat(depth); + + match &doc.node(id).kind { + NodeKind::Element { + name, + prefix, + namespace, + attributes, + } => { + let qname = match prefix { + Some(pfx) => format!("{pfx}:{name}"), + None => name.clone(), + }; + out.push_str(&indent); + out.push_str("ELEMENT "); + out.push_str(&qname); + if let Some(ns) = namespace { + let _ = write!(out, " ns={ns}"); + } + out.push('\n'); + for attr in attributes { + out.push_str(&indent); + out.push_str(" ATTRIBUTE "); + if let Some(pfx) = &attr.prefix { + out.push_str(pfx); + out.push(':'); + } + out.push_str(&attr.name); + out.push('='); + out.push_str(&attr.value); + out.push('\n'); + } + for child in doc.children(id) { + format_debug_node(doc, child, depth + 1, out); + } + } + NodeKind::Text { content } => { + out.push_str(&indent); + out.push_str("TEXT "); + // Show the text content, replacing newlines for readability + let display = content.replace('\n', "\\n"); + out.push_str(&display); + out.push('\n'); + } + NodeKind::CData { content } => { + out.push_str(&indent); + out.push_str("CDATA "); + out.push_str(content); + out.push('\n'); + } + NodeKind::Comment { content } => { + out.push_str(&indent); + out.push_str("COMMENT "); + out.push_str(content); + out.push('\n'); + } + NodeKind::ProcessingInstruction { target, data } => { + out.push_str(&indent); + out.push_str("PI "); + out.push_str(target); + if let Some(d) = data { + out.push(' '); + out.push_str(d); + } + out.push('\n'); + } + NodeKind::EntityRef { name, .. } => { + out.push_str(&indent); + out.push_str("ENTITY_REF "); + out.push_str(name); + out.push('\n'); + } + NodeKind::DocumentType { + name, + system_id, + public_id, + .. + } => { + out.push_str(&indent); + out.push_str("DOCTYPE "); + out.push_str(name); + if let Some(pub_id) = public_id { + let _ = write!(out, " PUBLIC \"{pub_id}\""); + } + if let Some(sys_id) = system_id { + let _ = write!(out, " SYSTEM \"{sys_id}\""); + } + out.push('\n'); + } + NodeKind::Document => { + out.push_str(&indent); + out.push_str("DOCUMENT\n"); + } + } +} + +// --------------------------------------------------------------------------- +// Output writing +// --------------------------------------------------------------------------- + +/// Writes output to stdout or to the file specified by --output. +fn write_output(cli: &Cli, content: &str) { + if let Some(ref output_file) = cli.output { + if let Err(e) = fs::write(output_file, content) { + eprintln!("{output_file}: failed to write: {e}"); + } + } else { + print!("{content}"); + // Flush stdout to ensure output is complete, especially when piped. + let _ = io::stdout().flush(); + } +} + +// --------------------------------------------------------------------------- +// DTD extraction helper +// --------------------------------------------------------------------------- + +/// Extracts the internal DTD subset text from the document, if any. +/// +/// Looks for a `DocumentType` node and attempts to extract a minimal DTD from +/// the document's content model. This is a best-effort approach -- a full +/// implementation would capture the internal subset during parsing. +fn extract_internal_dtd_subset(doc: &Document) -> String { + // Walk the document's top-level children looking for a DocumentType node. + for child in doc.children(doc.root()) { + if matches!(doc.node(child).kind, NodeKind::DocumentType { .. }) { + // We found a DOCTYPE but the current tree representation doesn't + // store the internal subset text. Return empty to indicate that + // the DTD can't be extracted from the tree alone. + return String::new(); + } + } + String::new() +} diff --git a/browser/vendor/xmloxide/src/catalog/mod.rs b/browser/vendor/xmloxide/src/catalog/mod.rs new file mode 100644 index 000000000..dd640b15b --- /dev/null +++ b/browser/vendor/xmloxide/src/catalog/mod.rs @@ -0,0 +1,1196 @@ +//! XML Catalogs for URI resolution (OASIS XML Catalogs 1.1). +//! +//! XML Catalogs provide a mechanism to map public identifiers and system +//! identifiers (URIs) to local resources. This enables offline validation, +//! entity resolution, and redirection of external resources to local copies. +//! +//! The implementation follows the [OASIS XML Catalogs 1.1](https://www.oasis-open.org/committees/entity/spec-2001-08-06.html) +//! specification and supports all standard catalog entry types: `public`, +//! `system`, `rewriteSystem`, `rewriteURI`, `uri`, `delegatePublic`, +//! `delegateSystem`, `nextCatalog`, `systemSuffix`, and `uriSuffix`. +//! +//! # Examples +//! +//! ``` +//! use xmloxide::catalog::{Catalog, CatalogEntry}; +//! +//! let mut catalog = Catalog::new(); +//! catalog.add_entry(CatalogEntry::Public { +//! public_id: "-//W3C//DTD XHTML 1.0 Strict//EN".to_string(), +//! uri: "dtd/xhtml1-strict.dtd".to_string(), +//! }); +//! +//! let resolved = catalog.resolve_public("-//W3C//DTD XHTML 1.0 Strict//EN"); +//! assert_eq!(resolved, Some("dtd/xhtml1-strict.dtd".to_string())); +//! ``` + +use std::fmt; + +use crate::tree::{Document, NodeKind}; + +/// The OASIS XML Catalog namespace URI. +const CATALOG_NAMESPACE: &str = "urn:oasis:names:tc:entity:xmlns:xml:catalog"; + +/// An XML Catalog for resolving public/system identifiers to local URIs. +/// +/// A catalog contains an ordered list of entries that are consulted during +/// identifier resolution. Entries are tried in order, with the first match +/// winning (except for rewrite rules, where the longest prefix match wins). +#[derive(Debug, Clone)] +pub struct Catalog { + entries: Vec<CatalogEntry>, +} + +/// A single entry in an XML catalog. +/// +/// Each variant corresponds to an element in the OASIS XML Catalog format. +/// The catalog processor tries entries in document order, with specific +/// matching rules for each type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CatalogEntry { + /// Maps a public identifier to a URI. + /// + /// Corresponds to the `<public>` element. The `public_id` is normalized + /// (leading/trailing whitespace stripped, internal whitespace collapsed) + /// before matching. + Public { + /// The public identifier to match. + public_id: String, + /// The URI to resolve to. + uri: String, + }, + + /// Maps a system identifier to a URI. + /// + /// Corresponds to the `<system>` element. The `system_id` must match + /// exactly (after URI normalization). + System { + /// The system identifier to match. + system_id: String, + /// The URI to resolve to. + uri: String, + }, + + /// Rewrites the beginning of a system identifier. + /// + /// Corresponds to the `<rewriteSystem>` element. When multiple rewrite + /// rules match, the one with the longest matching prefix wins. + RewriteSystem { + /// The prefix to match against the start of a system identifier. + start: String, + /// The replacement prefix. + rewrite_prefix: String, + }, + + /// Rewrites the beginning of a URI. + /// + /// Corresponds to the `<rewriteURI>` element. When multiple rewrite + /// rules match, the one with the longest matching prefix wins. + RewriteUri { + /// The prefix to match against the start of a URI. + start: String, + /// The replacement prefix. + rewrite_prefix: String, + }, + + /// Maps a URI to another URI. + /// + /// Corresponds to the `<uri>` element. The `name` must match exactly. + Uri { + /// The URI to match. + name: String, + /// The URI to resolve to. + uri: String, + }, + + /// Delegates matching public IDs to another catalog. + /// + /// Corresponds to the `<delegatePublic>` element. When a public + /// identifier starts with the given prefix, resolution is delegated + /// to the specified catalog file. + DelegatePublic { + /// The public identifier prefix to match. + start: String, + /// The URI of the catalog to delegate to. + catalog: String, + }, + + /// Delegates matching system IDs to another catalog. + /// + /// Corresponds to the `<delegateSystem>` element. When a system + /// identifier starts with the given prefix, resolution is delegated + /// to the specified catalog file. + DelegateSystem { + /// The system identifier prefix to match. + start: String, + /// The URI of the catalog to delegate to. + catalog: String, + }, + + /// Adds another catalog to search. + /// + /// Corresponds to the `<nextCatalog>` element. When resolution fails + /// in the current catalog, the next catalog is consulted. + NextCatalog { + /// The URI of the next catalog to search. + catalog: String, + }, + + /// System ID suffix matching. + /// + /// Corresponds to the `<systemSuffix>` element. Matches system + /// identifiers that end with the given suffix. + SystemSuffix { + /// The suffix to match against the end of a system identifier. + suffix: String, + /// The URI to resolve to. + uri: String, + }, + + /// URI suffix matching. + /// + /// Corresponds to the `<uriSuffix>` element. Matches URIs that end + /// with the given suffix. + UriSuffix { + /// The suffix to match against the end of a URI. + suffix: String, + /// The URI to resolve to. + uri: String, + }, +} + +/// An error that can occur during catalog parsing. +/// +/// This error is returned when the catalog XML cannot be parsed or when +/// the catalog structure does not conform to the OASIS XML Catalog format. +#[derive(Debug, Clone)] +pub struct CatalogError { + /// Human-readable description of the error. + pub message: String, +} + +impl fmt::Display for CatalogError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "catalog error: {}", self.message) + } +} + +impl std::error::Error for CatalogError {} + +impl Catalog { + /// Creates an empty catalog with no entries. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::catalog::Catalog; + /// + /// let catalog = Catalog::new(); + /// assert!(catalog.is_empty()); + /// ``` + #[must_use] + pub fn new() -> Self { + Self { + entries: Vec::new(), + } + } + + /// Parses an XML catalog from a string in OASIS XML Catalog format. + /// + /// The input must be a well-formed XML document with a root `<catalog>` + /// element in the `urn:oasis:names:tc:entity:xmlns:xml:catalog` namespace. + /// + /// # Errors + /// + /// Returns `CatalogError` if: + /// - The input is not well-formed XML + /// - The root element is not `<catalog>` in the catalog namespace + /// - Required attributes are missing on catalog entries + /// + /// # Examples + /// + /// ``` + /// use xmloxide::catalog::Catalog; + /// + /// let xml = r#"<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog"> + /// <public publicId="-//Example//EN" uri="example.dtd"/> + /// </catalog>"#; + /// + /// let catalog = Catalog::parse(xml).unwrap(); + /// assert_eq!(catalog.len(), 1); + /// ``` + pub fn parse(xml: &str) -> Result<Self, CatalogError> { + let doc = Document::parse_str(xml).map_err(|e| CatalogError { + message: format!("failed to parse catalog XML: {e}"), + })?; + + let root_element = doc.root_element().ok_or_else(|| CatalogError { + message: "catalog document has no root element".to_string(), + })?; + + // Verify the root element is <catalog> in the catalog namespace. + let root_name = doc.node_name(root_element).unwrap_or(""); + let root_ns = doc.node_namespace(root_element); + + if root_name != "catalog" { + return Err(CatalogError { + message: format!("expected root element 'catalog', found '{root_name}'"), + }); + } + + if root_ns != Some(CATALOG_NAMESPACE) { + return Err(CatalogError { + message: format!("root element must be in namespace '{CATALOG_NAMESPACE}'"), + }); + } + + let mut catalog = Self::new(); + + for child in doc.children(root_element) { + if let NodeKind::Element { ref name, .. } = doc.node(child).kind { + if let Some(entry) = parse_catalog_entry(&doc, child, name)? { + catalog.entries.push(entry); + } + } + } + + Ok(catalog) + } + + /// Adds an entry to the catalog. + /// + /// Entries are tried in insertion order during resolution. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::catalog::{Catalog, CatalogEntry}; + /// + /// let mut catalog = Catalog::new(); + /// catalog.add_entry(CatalogEntry::System { + /// system_id: "http://example.com/schema.xsd".to_string(), + /// uri: "local/schema.xsd".to_string(), + /// }); + /// assert_eq!(catalog.len(), 1); + /// ``` + pub fn add_entry(&mut self, entry: CatalogEntry) { + self.entries.push(entry); + } + + /// Resolves a public identifier to a URI. + /// + /// Searches catalog entries in order for a matching `Public` entry. + /// Public identifiers are compared after normalization (whitespace + /// collapsing). + /// + /// Returns `None` if no matching entry is found. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::catalog::{Catalog, CatalogEntry}; + /// + /// let mut catalog = Catalog::new(); + /// catalog.add_entry(CatalogEntry::Public { + /// public_id: "-//W3C//DTD XHTML 1.0//EN".to_string(), + /// uri: "xhtml1.dtd".to_string(), + /// }); + /// + /// assert_eq!( + /// catalog.resolve_public("-//W3C//DTD XHTML 1.0//EN"), + /// Some("xhtml1.dtd".to_string()) + /// ); + /// assert_eq!(catalog.resolve_public("-//Unknown//EN"), None); + /// ``` + #[must_use] + pub fn resolve_public(&self, public_id: &str) -> Option<String> { + let normalized = normalize_public_id(public_id); + + // Try exact public match first. + for entry in &self.entries { + if let CatalogEntry::Public { + public_id: ref pid, + ref uri, + } = *entry + { + if normalize_public_id(pid) == normalized { + return Some(uri.clone()); + } + } + } + + // Try delegatePublic matches (longest prefix wins). + let mut best_delegate: Option<(&str, usize)> = None; + for entry in &self.entries { + if let CatalogEntry::DelegatePublic { + ref start, + ref catalog, + } = *entry + { + if normalized.starts_with(start.as_str()) + && start.len() > best_delegate.map_or(0, |(_, len)| len) + { + best_delegate = Some((catalog.as_str(), start.len())); + } + } + } + + if let Some((catalog_uri, _)) = best_delegate { + return Some(catalog_uri.to_string()); + } + + None + } + + /// Resolves a system identifier to a URI. + /// + /// The resolution order is: + /// 1. Exact `System` match + /// 2. `RewriteSystem` prefix match (longest prefix wins) + /// 3. `SystemSuffix` suffix match (longest suffix wins) + /// 4. `DelegateSystem` prefix match (longest prefix wins) + /// + /// Returns `None` if no matching entry is found. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::catalog::{Catalog, CatalogEntry}; + /// + /// let mut catalog = Catalog::new(); + /// catalog.add_entry(CatalogEntry::System { + /// system_id: "http://example.com/schema.xsd".to_string(), + /// uri: "local/schema.xsd".to_string(), + /// }); + /// + /// assert_eq!( + /// catalog.resolve_system("http://example.com/schema.xsd"), + /// Some("local/schema.xsd".to_string()) + /// ); + /// ``` + #[must_use] + pub fn resolve_system(&self, system_id: &str) -> Option<String> { + // 1. Try exact system match. + for entry in &self.entries { + if let CatalogEntry::System { + system_id: ref sid, + ref uri, + } = *entry + { + if sid == system_id { + return Some(uri.clone()); + } + } + } + + // 2. Try rewriteSystem (longest prefix wins). + if let Some(result) = self.resolve_rewrite_system(system_id) { + return Some(result); + } + + // 3. Try systemSuffix (longest suffix wins). + if let Some(result) = self.resolve_system_suffix(system_id) { + return Some(result); + } + + // 4. Try delegateSystem (longest prefix wins). + let mut best_delegate: Option<(&str, usize)> = None; + for entry in &self.entries { + if let CatalogEntry::DelegateSystem { + ref start, + ref catalog, + } = *entry + { + if system_id.starts_with(start.as_str()) + && start.len() > best_delegate.map_or(0, |(_, len)| len) + { + best_delegate = Some((catalog.as_str(), start.len())); + } + } + } + + if let Some((catalog_uri, _)) = best_delegate { + return Some(catalog_uri.to_string()); + } + + None + } + + /// Resolves a URI reference. + /// + /// The resolution order is: + /// 1. Exact `Uri` match + /// 2. `RewriteUri` prefix match (longest prefix wins) + /// 3. `UriSuffix` suffix match (longest suffix wins) + /// + /// Returns `None` if no matching entry is found. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::catalog::{Catalog, CatalogEntry}; + /// + /// let mut catalog = Catalog::new(); + /// catalog.add_entry(CatalogEntry::Uri { + /// name: "http://example.com/schema.xsd".to_string(), + /// uri: "local/schema.xsd".to_string(), + /// }); + /// + /// assert_eq!( + /// catalog.resolve_uri("http://example.com/schema.xsd"), + /// Some("local/schema.xsd".to_string()) + /// ); + /// ``` + #[must_use] + pub fn resolve_uri(&self, uri: &str) -> Option<String> { + // 1. Try exact URI match. + for entry in &self.entries { + if let CatalogEntry::Uri { + ref name, + uri: ref target, + } = *entry + { + if name == uri { + return Some(target.clone()); + } + } + } + + // 2. Try rewriteURI (longest prefix wins). + if let Some(result) = self.resolve_rewrite_uri(uri) { + return Some(result); + } + + // 3. Try uriSuffix (longest suffix wins). + let mut best_suffix: Option<(&str, usize)> = None; + for entry in &self.entries { + if let CatalogEntry::UriSuffix { + ref suffix, + uri: ref target, + } = *entry + { + if uri.ends_with(suffix.as_str()) + && suffix.len() > best_suffix.map_or(0, |(_, len)| len) + { + best_suffix = Some((target.as_str(), suffix.len())); + } + } + } + + if let Some((target, _)) = best_suffix { + return Some(target.to_string()); + } + + None + } + + /// Resolves either a public or system identifier, trying system first. + /// + /// This is the primary resolution method that follows the OASIS catalog + /// resolution algorithm: system identifiers take precedence over public + /// identifiers because they are more specific. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::catalog::{Catalog, CatalogEntry}; + /// + /// let mut catalog = Catalog::new(); + /// catalog.add_entry(CatalogEntry::Public { + /// public_id: "-//Example//EN".to_string(), + /// uri: "public.dtd".to_string(), + /// }); + /// catalog.add_entry(CatalogEntry::System { + /// system_id: "http://example.com/doc.dtd".to_string(), + /// uri: "system.dtd".to_string(), + /// }); + /// + /// // System takes precedence. + /// assert_eq!( + /// catalog.resolve(Some("-//Example//EN"), Some("http://example.com/doc.dtd")), + /// Some("system.dtd".to_string()) + /// ); + /// + /// // Falls back to public when system is not provided. + /// assert_eq!( + /// catalog.resolve(Some("-//Example//EN"), None), + /// Some("public.dtd".to_string()) + /// ); + /// ``` + #[must_use] + pub fn resolve(&self, public_id: Option<&str>, system_id: Option<&str>) -> Option<String> { + // Try system identifier first (more specific). + if let Some(sid) = system_id { + if let Some(resolved) = self.resolve_system(sid) { + return Some(resolved); + } + } + + // Fall back to public identifier. + if let Some(pid) = public_id { + if let Some(resolved) = self.resolve_public(pid) { + return Some(resolved); + } + } + + None + } + + /// Merges another catalog's entries into this one. + /// + /// All entries from `other` are appended to this catalog's entry list, + /// preserving order. The other catalog's entries will be tried after + /// the existing entries during resolution. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::catalog::{Catalog, CatalogEntry}; + /// + /// let mut catalog1 = Catalog::new(); + /// catalog1.add_entry(CatalogEntry::Public { + /// public_id: "-//A//EN".to_string(), + /// uri: "a.dtd".to_string(), + /// }); + /// + /// let mut catalog2 = Catalog::new(); + /// catalog2.add_entry(CatalogEntry::Public { + /// public_id: "-//B//EN".to_string(), + /// uri: "b.dtd".to_string(), + /// }); + /// + /// catalog1.merge(&catalog2); + /// assert_eq!(catalog1.len(), 2); + /// ``` + pub fn merge(&mut self, other: &Catalog) { + self.entries.extend(other.entries.iter().cloned()); + } + + /// Returns the number of entries in the catalog. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::catalog::Catalog; + /// + /// let catalog = Catalog::new(); + /// assert_eq!(catalog.len(), 0); + /// ``` + #[must_use] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Returns `true` if the catalog has no entries. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::catalog::Catalog; + /// + /// let catalog = Catalog::new(); + /// assert!(catalog.is_empty()); + /// ``` + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Returns an iterator over the catalog entries. + pub fn entries(&self) -> impl Iterator<Item = &CatalogEntry> { + self.entries.iter() + } + + // --- Private resolution helpers --- + + /// Finds the best `RewriteSystem` match for the given system ID. + /// + /// Among all `RewriteSystem` entries whose `start` is a prefix of + /// `system_id`, the one with the longest `start` wins. + fn resolve_rewrite_system(&self, system_id: &str) -> Option<String> { + let mut best: Option<(&str, &str, usize)> = None; + + for entry in &self.entries { + if let CatalogEntry::RewriteSystem { + ref start, + ref rewrite_prefix, + } = *entry + { + if system_id.starts_with(start.as_str()) + && start.len() > best.map_or(0, |(_, _, len)| len) + { + best = Some((start.as_str(), rewrite_prefix.as_str(), start.len())); + } + } + } + + best.map(|(start, rewrite_prefix, _)| { + format!("{rewrite_prefix}{}", &system_id[start.len()..]) + }) + } + + /// Finds the best `SystemSuffix` match for the given system ID. + /// + /// Among all `SystemSuffix` entries whose `suffix` matches the end of + /// `system_id`, the one with the longest `suffix` wins. + fn resolve_system_suffix(&self, system_id: &str) -> Option<String> { + let mut best: Option<(&str, usize)> = None; + + for entry in &self.entries { + if let CatalogEntry::SystemSuffix { + ref suffix, + ref uri, + } = *entry + { + if system_id.ends_with(suffix.as_str()) + && suffix.len() > best.map_or(0, |(_, len)| len) + { + best = Some((uri.as_str(), suffix.len())); + } + } + } + + best.map(|(uri, _)| uri.to_string()) + } + + /// Finds the best `RewriteUri` match for the given URI. + fn resolve_rewrite_uri(&self, uri: &str) -> Option<String> { + let mut best: Option<(&str, &str, usize)> = None; + + for entry in &self.entries { + if let CatalogEntry::RewriteUri { + ref start, + ref rewrite_prefix, + } = *entry + { + if uri.starts_with(start.as_str()) + && start.len() > best.map_or(0, |(_, _, len)| len) + { + best = Some((start.as_str(), rewrite_prefix.as_str(), start.len())); + } + } + } + + best.map(|(start, rewrite_prefix, _)| format!("{rewrite_prefix}{}", &uri[start.len()..])) + } +} + +impl Default for Catalog { + fn default() -> Self { + Self::new() + } +} + +/// Normalizes a public identifier by collapsing whitespace. +/// +/// Per the OASIS catalog specification, public identifiers are compared +/// after stripping leading/trailing whitespace and collapsing all internal +/// whitespace sequences to a single space. +fn normalize_public_id(public_id: &str) -> String { + public_id.split_whitespace().collect::<Vec<_>>().join(" ") +} + +/// Parses a single catalog entry element into a `CatalogEntry`. +/// +/// Returns `Ok(None)` for unrecognized elements (which are silently ignored +/// per the catalog specification). Returns `Err` if a recognized element +/// is missing required attributes. +fn parse_catalog_entry( + doc: &Document, + node: crate::NodeId, + name: &str, +) -> Result<Option<CatalogEntry>, CatalogError> { + match name { + "public" => { + let public_id = require_attr(doc, node, "publicId", "public")?; + let uri = require_attr(doc, node, "uri", "public")?; + Ok(Some(CatalogEntry::Public { public_id, uri })) + } + "system" => { + let system_id = require_attr(doc, node, "systemId", "system")?; + let uri = require_attr(doc, node, "uri", "system")?; + Ok(Some(CatalogEntry::System { system_id, uri })) + } + "rewriteSystem" => { + let start = require_attr(doc, node, "systemIdStartString", "rewriteSystem")?; + let rewrite_prefix = require_attr(doc, node, "rewritePrefix", "rewriteSystem")?; + Ok(Some(CatalogEntry::RewriteSystem { + start, + rewrite_prefix, + })) + } + "rewriteURI" => { + let start = require_attr(doc, node, "uriStartString", "rewriteURI")?; + let rewrite_prefix = require_attr(doc, node, "rewritePrefix", "rewriteURI")?; + Ok(Some(CatalogEntry::RewriteUri { + start, + rewrite_prefix, + })) + } + "uri" => { + let name = require_attr(doc, node, "name", "uri")?; + let uri = require_attr(doc, node, "uri", "uri")?; + Ok(Some(CatalogEntry::Uri { name, uri })) + } + "delegatePublic" => { + let start = require_attr(doc, node, "publicIdStartString", "delegatePublic")?; + let catalog = require_attr(doc, node, "catalog", "delegatePublic")?; + Ok(Some(CatalogEntry::DelegatePublic { start, catalog })) + } + "delegateSystem" => { + let start = require_attr(doc, node, "systemIdStartString", "delegateSystem")?; + let catalog = require_attr(doc, node, "catalog", "delegateSystem")?; + Ok(Some(CatalogEntry::DelegateSystem { start, catalog })) + } + "nextCatalog" => { + let catalog = require_attr(doc, node, "catalog", "nextCatalog")?; + Ok(Some(CatalogEntry::NextCatalog { catalog })) + } + "systemSuffix" => { + let suffix = require_attr(doc, node, "systemIdSuffix", "systemSuffix")?; + let uri = require_attr(doc, node, "uri", "systemSuffix")?; + Ok(Some(CatalogEntry::SystemSuffix { suffix, uri })) + } + "uriSuffix" => { + let suffix = require_attr(doc, node, "uriSuffix", "uriSuffix")?; + let uri = require_attr(doc, node, "uri", "uriSuffix")?; + Ok(Some(CatalogEntry::UriSuffix { suffix, uri })) + } + // Unrecognized elements in the catalog namespace are silently ignored, + // following the extensibility rules in the OASIS specification. + _ => Ok(None), + } +} + +/// Extracts a required attribute from an element, returning a `CatalogError` +/// if the attribute is missing. +fn require_attr( + doc: &Document, + node: crate::NodeId, + attr_name: &str, + element_name: &str, +) -> Result<String, CatalogError> { + doc.attribute(node, attr_name) + .map(ToString::to_string) + .ok_or_else(|| CatalogError { + message: format!( + "missing required attribute '{attr_name}' on <{element_name}> element" + ), + }) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn catalog_xml(body: &str) -> String { + format!(r#"<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">{body}</catalog>"#) + } + + #[test] + fn test_parse_simple_catalog_with_public_entry() { + let xml = catalog_xml( + r#"<public publicId="-//W3C//DTD XHTML 1.0 Strict//EN" uri="dtd/xhtml1-strict.dtd"/>"#, + ); + let catalog = Catalog::parse(&xml).unwrap(); + assert_eq!(catalog.len(), 1); + assert_eq!( + catalog.entries().next(), + Some(&CatalogEntry::Public { + public_id: "-//W3C//DTD XHTML 1.0 Strict//EN".to_string(), + uri: "dtd/xhtml1-strict.dtd".to_string(), + }) + ); + } + + #[test] + fn test_parse_catalog_with_system_entry() { + let xml = catalog_xml( + r#"<system systemId="http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd" uri="dtd/xhtml1-strict.dtd"/>"#, + ); + let catalog = Catalog::parse(&xml).unwrap(); + assert_eq!(catalog.len(), 1); + assert_eq!( + catalog.entries().next(), + Some(&CatalogEntry::System { + system_id: "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd".to_string(), + uri: "dtd/xhtml1-strict.dtd".to_string(), + }) + ); + } + + #[test] + fn test_parse_catalog_with_rewrite_entries() { + let xml = catalog_xml( + r#"<rewriteSystem systemIdStartString="http://www.w3.org/TR/" rewritePrefix="file:///usr/share/xml/w3c/"/> + <rewriteURI uriStartString="http://example.com/" rewritePrefix="file:///local/"/>"#, + ); + let catalog = Catalog::parse(&xml).unwrap(); + assert_eq!(catalog.len(), 2); + } + + #[test] + fn test_resolve_public_identifier() { + let mut catalog = Catalog::new(); + catalog.add_entry(CatalogEntry::Public { + public_id: "-//W3C//DTD XHTML 1.0 Strict//EN".to_string(), + uri: "dtd/xhtml1-strict.dtd".to_string(), + }); + + assert_eq!( + catalog.resolve_public("-//W3C//DTD XHTML 1.0 Strict//EN"), + Some("dtd/xhtml1-strict.dtd".to_string()) + ); + } + + #[test] + fn test_resolve_system_identifier() { + let mut catalog = Catalog::new(); + catalog.add_entry(CatalogEntry::System { + system_id: "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd".to_string(), + uri: "dtd/xhtml1-strict.dtd".to_string(), + }); + + assert_eq!( + catalog.resolve_system("http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"), + Some("dtd/xhtml1-strict.dtd".to_string()) + ); + } + + #[test] + fn test_resolve_system_with_rewrite_prefix() { + let mut catalog = Catalog::new(); + catalog.add_entry(CatalogEntry::RewriteSystem { + start: "http://www.w3.org/TR/".to_string(), + rewrite_prefix: "file:///usr/share/xml/w3c/".to_string(), + }); + + assert_eq!( + catalog.resolve_system("http://www.w3.org/TR/xhtml1/DTD/strict.dtd"), + Some("file:///usr/share/xml/w3c/xhtml1/DTD/strict.dtd".to_string()) + ); + } + + #[test] + fn test_resolve_uri() { + let mut catalog = Catalog::new(); + catalog.add_entry(CatalogEntry::Uri { + name: "http://example.com/schema.xsd".to_string(), + uri: "local/schema.xsd".to_string(), + }); + + assert_eq!( + catalog.resolve_uri("http://example.com/schema.xsd"), + Some("local/schema.xsd".to_string()) + ); + } + + #[test] + fn test_resolve_with_suffix_matching() { + let mut catalog = Catalog::new(); + catalog.add_entry(CatalogEntry::SystemSuffix { + suffix: "strict.dtd".to_string(), + uri: "local/strict.dtd".to_string(), + }); + + assert_eq!( + catalog.resolve_system("http://example.com/path/to/strict.dtd"), + Some("local/strict.dtd".to_string()) + ); + } + + #[test] + fn test_no_match_returns_none() { + let catalog = Catalog::new(); + assert_eq!(catalog.resolve_public("-//Unknown//EN"), None); + assert_eq!( + catalog.resolve_system("http://unknown.example.com/foo"), + None + ); + assert_eq!(catalog.resolve_uri("http://unknown.example.com/bar"), None); + assert_eq!(catalog.resolve(None, None), None); + } + + #[test] + fn test_merge_two_catalogs() { + let mut catalog1 = Catalog::new(); + catalog1.add_entry(CatalogEntry::Public { + public_id: "-//A//EN".to_string(), + uri: "a.dtd".to_string(), + }); + + let mut catalog2 = Catalog::new(); + catalog2.add_entry(CatalogEntry::Public { + public_id: "-//B//EN".to_string(), + uri: "b.dtd".to_string(), + }); + + catalog1.merge(&catalog2); + assert_eq!(catalog1.len(), 2); + assert_eq!( + catalog1.resolve_public("-//A//EN"), + Some("a.dtd".to_string()) + ); + assert_eq!( + catalog1.resolve_public("-//B//EN"), + Some("b.dtd".to_string()) + ); + } + + #[test] + fn test_empty_catalog() { + let catalog = Catalog::new(); + assert!(catalog.is_empty()); + assert_eq!(catalog.len(), 0); + } + + #[test] + fn test_add_entry_programmatically() { + let mut catalog = Catalog::new(); + assert!(catalog.is_empty()); + + catalog.add_entry(CatalogEntry::System { + system_id: "http://example.com/test.dtd".to_string(), + uri: "test.dtd".to_string(), + }); + + assert!(!catalog.is_empty()); + assert_eq!(catalog.len(), 1); + assert_eq!( + catalog.resolve_system("http://example.com/test.dtd"), + Some("test.dtd".to_string()) + ); + } + + #[test] + fn test_catalog_len_and_is_empty() { + let mut catalog = Catalog::new(); + assert_eq!(catalog.len(), 0); + assert!(catalog.is_empty()); + + catalog.add_entry(CatalogEntry::NextCatalog { + catalog: "other.xml".to_string(), + }); + assert_eq!(catalog.len(), 1); + assert!(!catalog.is_empty()); + + catalog.add_entry(CatalogEntry::NextCatalog { + catalog: "another.xml".to_string(), + }); + assert_eq!(catalog.len(), 2); + } + + #[test] + fn test_complex_catalog_with_multiple_entry_types() { + let xml = catalog_xml( + r#"<public publicId="-//W3C//DTD XHTML 1.0 Strict//EN" uri="dtd/xhtml1-strict.dtd"/> + <system systemId="http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd" uri="dtd/xhtml1-strict.dtd"/> + <rewriteSystem systemIdStartString="http://www.w3.org/TR/" rewritePrefix="file:///local/w3c/"/> + <uri name="http://example.com/schema.xsd" uri="local/schema.xsd"/> + <nextCatalog catalog="other-catalog.xml"/>"#, + ); + + let catalog = Catalog::parse(&xml).unwrap(); + assert_eq!(catalog.len(), 5); + + assert!(catalog + .resolve_public("-//W3C//DTD XHTML 1.0 Strict//EN") + .is_some()); + assert!(catalog + .resolve_system("http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd") + .is_some()); + assert!(catalog + .resolve_system("http://www.w3.org/TR/other/doc.xml") + .is_some()); + assert!(catalog + .resolve_uri("http://example.com/schema.xsd") + .is_some()); + } + + #[test] + fn test_resolve_prefers_system_over_public() { + let mut catalog = Catalog::new(); + catalog.add_entry(CatalogEntry::Public { + public_id: "-//Example//EN".to_string(), + uri: "public-result.dtd".to_string(), + }); + catalog.add_entry(CatalogEntry::System { + system_id: "http://example.com/doc.dtd".to_string(), + uri: "system-result.dtd".to_string(), + }); + + // When both are provided, system wins. + assert_eq!( + catalog.resolve(Some("-//Example//EN"), Some("http://example.com/doc.dtd")), + Some("system-result.dtd".to_string()) + ); + + // When only public is provided, public wins. + assert_eq!( + catalog.resolve(Some("-//Example//EN"), None), + Some("public-result.dtd".to_string()) + ); + + // When only system is provided, system wins. + assert_eq!( + catalog.resolve(None, Some("http://example.com/doc.dtd")), + Some("system-result.dtd".to_string()) + ); + } + + #[test] + fn test_rewrite_system_longest_prefix_wins() { + let mut catalog = Catalog::new(); + catalog.add_entry(CatalogEntry::RewriteSystem { + start: "http://www.w3.org/".to_string(), + rewrite_prefix: "file:///short/".to_string(), + }); + catalog.add_entry(CatalogEntry::RewriteSystem { + start: "http://www.w3.org/TR/xhtml1/".to_string(), + rewrite_prefix: "file:///long/".to_string(), + }); + + // The longer prefix match should win. + assert_eq!( + catalog.resolve_system("http://www.w3.org/TR/xhtml1/DTD/strict.dtd"), + Some("file:///long/DTD/strict.dtd".to_string()) + ); + + // A URL that only matches the shorter prefix. + assert_eq!( + catalog.resolve_system("http://www.w3.org/other/file.xml"), + Some("file:///short/other/file.xml".to_string()) + ); + } + + #[test] + fn test_catalog_error_display() { + let err = CatalogError { + message: "missing required attribute".to_string(), + }; + assert_eq!(err.to_string(), "catalog error: missing required attribute"); + } + + #[test] + fn test_parse_invalid_xml_returns_error() { + let result = Catalog::parse("not valid xml <><>"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_wrong_root_element() { + let xml = r#"<notcatalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog"/>"#; + let result = Catalog::parse(xml); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.message.contains("expected root element 'catalog'")); + } + + #[test] + fn test_parse_missing_namespace() { + let xml = r#"<catalog><public publicId="test" uri="test.dtd"/></catalog>"#; + let result = Catalog::parse(xml); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.message.contains("namespace")); + } + + #[test] + fn test_parse_missing_required_attribute() { + let xml = catalog_xml(r#"<public publicId="-//Test//EN"/>"#); + let result = Catalog::parse(&xml); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.message.contains("uri")); + } + + #[test] + fn test_public_id_whitespace_normalization() { + let mut catalog = Catalog::new(); + catalog.add_entry(CatalogEntry::Public { + public_id: "-//W3C//DTD XHTML 1.0//EN".to_string(), + uri: "xhtml.dtd".to_string(), + }); + + // Extra whitespace in the query should still match. + assert_eq!( + catalog.resolve_public("-//W3C//DTD XHTML 1.0//EN"), + Some("xhtml.dtd".to_string()) + ); + } + + #[test] + fn test_uri_suffix_matching() { + let mut catalog = Catalog::new(); + catalog.add_entry(CatalogEntry::UriSuffix { + suffix: "schema.xsd".to_string(), + uri: "local/schema.xsd".to_string(), + }); + + assert_eq!( + catalog.resolve_uri("http://example.com/path/to/schema.xsd"), + Some("local/schema.xsd".to_string()) + ); + assert_eq!(catalog.resolve_uri("http://example.com/other.xsd"), None); + } + + #[test] + fn test_rewrite_uri() { + let mut catalog = Catalog::new(); + catalog.add_entry(CatalogEntry::RewriteUri { + start: "http://example.com/schemas/".to_string(), + rewrite_prefix: "file:///local/schemas/".to_string(), + }); + + assert_eq!( + catalog.resolve_uri("http://example.com/schemas/types/main.xsd"), + Some("file:///local/schemas/types/main.xsd".to_string()) + ); + } + + #[test] + fn test_delegate_public() { + let mut catalog = Catalog::new(); + catalog.add_entry(CatalogEntry::DelegatePublic { + start: "-//W3C//".to_string(), + catalog: "w3c-catalog.xml".to_string(), + }); + + // DelegatePublic returns the catalog URI for matching public IDs. + assert_eq!( + catalog.resolve_public("-//W3C//DTD XHTML 1.0//EN"), + Some("w3c-catalog.xml".to_string()) + ); + assert_eq!(catalog.resolve_public("-//OASIS//DTD DocBook//EN"), None); + } + + #[test] + fn test_delegate_system() { + let mut catalog = Catalog::new(); + catalog.add_entry(CatalogEntry::DelegateSystem { + start: "http://www.w3.org/".to_string(), + catalog: "w3c-catalog.xml".to_string(), + }); + + assert_eq!( + catalog.resolve_system("http://www.w3.org/TR/xhtml1/DTD/strict.dtd"), + Some("w3c-catalog.xml".to_string()) + ); + assert_eq!(catalog.resolve_system("http://example.com/other.dtd"), None); + } + + #[test] + fn test_default_trait() { + let catalog = Catalog::default(); + assert!(catalog.is_empty()); + } + + #[test] + fn test_catalog_error_is_error_trait() { + let err = CatalogError { + message: "test error".to_string(), + }; + let _: &dyn std::error::Error = &err; + } +} diff --git a/browser/vendor/xmloxide/src/css/eval.rs b/browser/vendor/xmloxide/src/css/eval.rs new file mode 100644 index 000000000..462c42b4c --- /dev/null +++ b/browser/vendor/xmloxide/src/css/eval.rs @@ -0,0 +1,987 @@ +//! CSS selector evaluation against a [`Document`] tree. + +use crate::tree::{Document, NodeId, NodeKind}; + +use super::types::{ + AttrOp, AttrSelector, Combinator, CompoundSelector, NthExpr, PseudoClass, Selector, + SelectorGroup, +}; + +/// Evaluate a parsed selector group against the document, starting from `scope`. +/// +/// Returns all descendant nodes of `scope` that match any selector in the group. +pub fn select(doc: &Document, scope: NodeId, group: &SelectorGroup) -> Vec<NodeId> { + // Fast path: if every selector in the group is a simple `#id` selector, + // use element_by_id for O(1) lookup instead of walking the tree. + if let Some(results) = try_fast_id_select(doc, scope, group) { + return results; + } + + let mut results = Vec::new(); + collect_descendants(doc, scope, group, &mut results); + results +} + +/// Attempts to use the fast `id_map` for pure `#id` selectors. +/// Returns `None` if any selector is not a simple ID selector. +fn try_fast_id_select(doc: &Document, scope: NodeId, group: &SelectorGroup) -> Option<Vec<NodeId>> { + let mut results = Vec::new(); + for sel in &group.selectors { + // Must be a single compound with only an ID + if sel.compounds.len() != 1 { + return None; + } + let compound = &sel.compounds[0].compound; + let id = compound.id.as_ref()?; + if compound.tag.is_some() + || !compound.classes.is_empty() + || !compound.attrs.is_empty() + || !compound.pseudos.is_empty() + { + return None; + } + + // Look up via id_map + if let Some(node) = doc.element_by_id(id) { + // Verify the node is a descendant of scope + if is_descendant_of(doc, node, scope) && !results.contains(&node) { + results.push(node); + } + } + } + Some(results) +} + +/// Returns true if `node` is a descendant of `ancestor`. +fn is_descendant_of(doc: &Document, node: NodeId, ancestor: NodeId) -> bool { + let mut current = doc.parent(node); + while let Some(id) = current { + if id == ancestor { + return true; + } + current = doc.parent(id); + } + false +} + +/// Recursively collect matching descendants. +fn collect_descendants( + doc: &Document, + node: NodeId, + group: &SelectorGroup, + results: &mut Vec<NodeId>, +) { + for child in doc.children(node) { + if matches!(doc.node(child).kind, NodeKind::Element { .. }) { + if group + .selectors + .iter() + .any(|sel| matches_selector(doc, child, sel)) + { + results.push(child); + } + collect_descendants(doc, child, group, results); + } + } +} + +/// Check if a node matches a complete selector (chain of compounds with combinators). +fn matches_selector(doc: &Document, node: NodeId, selector: &Selector) -> bool { + // Walk the compound chain backwards from the rightmost (subject) compound + let compounds = &selector.compounds; + if compounds.is_empty() { + return false; + } + + // The last compound must match the node itself + let last = compounds.len() - 1; + if !matches_compound(doc, node, &compounds[last].compound) { + return false; + } + + // Walk backwards through the chain + let mut current = node; + for i in (0..last).rev() { + let entry = &compounds[i]; + let next_combinator = compounds[i + 1].combinator; + match next_combinator { + Combinator::None => {} + Combinator::Descendant => { + // Find an ancestor that matches + let mut found = false; + let mut ancestor = doc.parent(current); + while let Some(anc) = ancestor { + if matches!(doc.node(anc).kind, NodeKind::Element { .. }) + && matches_compound(doc, anc, &entry.compound) + { + current = anc; + found = true; + break; + } + ancestor = doc.parent(anc); + } + if !found { + return false; + } + } + Combinator::Child => { + // Parent must match + if let Some(parent) = doc.parent(current) { + if matches!(doc.node(parent).kind, NodeKind::Element { .. }) + && matches_compound(doc, parent, &entry.compound) + { + current = parent; + } else { + return false; + } + } else { + return false; + } + } + Combinator::NextSibling => { + // Previous sibling element must match + if let Some(prev) = prev_element_sibling(doc, current) { + if matches_compound(doc, prev, &entry.compound) { + current = prev; + } else { + return false; + } + } else { + return false; + } + } + Combinator::SubsequentSibling => { + // Any preceding sibling element must match + let mut found = false; + let mut prev = prev_element_sibling(doc, current); + while let Some(p) = prev { + if matches_compound(doc, p, &entry.compound) { + current = p; + found = true; + break; + } + prev = prev_element_sibling(doc, p); + } + if !found { + return false; + } + } + } + } + + true +} + +/// Check if a node matches a compound selector (all simple selectors must match). +fn matches_compound(doc: &Document, node: NodeId, compound: &CompoundSelector) -> bool { + // Tag name + if let Some(ref tag) = compound.tag { + let name = doc.node_name(node).unwrap_or(""); + if !name.eq_ignore_ascii_case(tag) { + return false; + } + } + + // ID — use element_by_id for O(1) lookup when the id_map is populated, + // falling back to attribute scan when it's not. + if let Some(ref id) = compound.id { + if let Some(target) = doc.element_by_id(id) { + if target != node { + return false; + } + } else { + // id_map doesn't have this ID — either the element doesn't exist + // or the id_map wasn't populated. Fall back to attribute scan. + let node_id_attr = doc.attribute(node, "id").unwrap_or(""); + if node_id_attr != id { + return false; + } + } + } + + // Classes + for class in &compound.classes { + let class_attr = doc.attribute(node, "class").unwrap_or(""); + if !class_attr.split_ascii_whitespace().any(|c| c == class) { + return false; + } + } + + // Attribute selectors + for attr in &compound.attrs { + if !matches_attr(doc, node, attr) { + return false; + } + } + + // Pseudo-classes + for pseudo in &compound.pseudos { + if !matches_pseudo(doc, node, pseudo) { + return false; + } + } + + true +} + +/// Check if a node matches an attribute selector. +fn matches_attr(doc: &Document, node: NodeId, sel: &AttrSelector) -> bool { + let Some(value) = doc.attribute(node, &sel.name) else { + return false; + }; + + let Some(matcher) = &sel.matcher else { + return true; // existence check only + }; + + let (val, expected) = if matcher.case_insensitive { + ( + value.to_ascii_lowercase(), + matcher.value.to_ascii_lowercase(), + ) + } else { + (value.to_string(), matcher.value.clone()) + }; + + match matcher.op { + AttrOp::Exact => val == expected, + AttrOp::Word => val.split_ascii_whitespace().any(|w| w == expected), + AttrOp::DashPrefix => val == expected || val.starts_with(&format!("{expected}-")), + AttrOp::Prefix => val.starts_with(&expected), + AttrOp::Suffix => val.ends_with(&expected), + AttrOp::Substring => val.contains(&expected), + } +} + +/// Check if a node matches a pseudo-class. +fn matches_pseudo(doc: &Document, node: NodeId, pseudo: &PseudoClass) -> bool { + match pseudo { + PseudoClass::FirstChild => { + // Node is the first element child of its parent + doc.parent(node) + .and_then(|p| first_element_child(doc, p)) + .is_some_and(|first| first == node) + } + PseudoClass::LastChild => doc + .parent(node) + .and_then(|p| last_element_child(doc, p)) + .is_some_and(|last| last == node), + PseudoClass::OnlyChild => { + if let Some(parent) = doc.parent(node) { + let element_children: Vec<_> = doc + .children(parent) + .filter(|&c| matches!(doc.node(c).kind, NodeKind::Element { .. })) + .collect(); + element_children.len() == 1 && element_children[0] == node + } else { + false + } + } + PseudoClass::Empty => { + // No child elements or text nodes + !doc.children(node).any(|c| { + matches!( + doc.node(c).kind, + NodeKind::Element { .. } | NodeKind::Text { .. } | NodeKind::CData { .. } + ) + }) + } + PseudoClass::Not(inner) => !matches_compound(doc, node, inner), + PseudoClass::NthChild(expr) => nth_child_matches(doc, node, *expr, false), + PseudoClass::NthLastChild(expr) => nth_child_matches(doc, node, *expr, true), + } +} + +/// Check if a node's position among sibling elements matches an `An+B` expression. +fn nth_child_matches(doc: &Document, node: NodeId, expr: NthExpr, from_end: bool) -> bool { + let Some(parent) = doc.parent(node) else { + return false; + }; + + let element_children: Vec<_> = doc + .children(parent) + .filter(|&c| matches!(doc.node(c).kind, NodeKind::Element { .. })) + .collect(); + + #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] + let pos = if from_end { + element_children + .iter() + .rev() + .position(|&c| c == node) + .map(|p| p as i32 + 1) + } else { + element_children + .iter() + .position(|&c| c == node) + .map(|p| p as i32 + 1) + }; + + pos.is_some_and(|p| expr.matches(p)) +} + +/// Find the previous element sibling of a node. +fn prev_element_sibling(doc: &Document, node: NodeId) -> Option<NodeId> { + let mut prev = doc.prev_sibling(node); + while let Some(p) = prev { + if matches!(doc.node(p).kind, NodeKind::Element { .. }) { + return Some(p); + } + prev = doc.prev_sibling(p); + } + None +} + +/// Find the first element child. +fn first_element_child(doc: &Document, parent: NodeId) -> Option<NodeId> { + doc.children(parent) + .find(|&c| matches!(doc.node(c).kind, NodeKind::Element { .. })) +} + +/// Find the last element child. +fn last_element_child(doc: &Document, parent: NodeId) -> Option<NodeId> { + doc.children(parent) + .filter(|&c| matches!(doc.node(c).kind, NodeKind::Element { .. })) + .last() +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::css::parser::parse_selector; + use crate::tree::Document; + + /// Helper: parse a selector string and evaluate it against the document. + fn eval(doc: &Document, scope: NodeId, css: &str) -> Vec<NodeId> { + let group = parse_selector(css).unwrap(); + select(doc, scope, &group) + } + + /// Shared test document covering common structures. + fn test_doc() -> Document { + Document::parse_str( + r#"<root> + <div id="main" class="container wide"> + <h1>Title</h1> + <p class="intro">Hello</p> + <p class="body">World</p> + <ul> + <li class="active">One</li> + <li>Two</li> + <li class="last">Three</li> + </ul> + <a href="https://example.com" data-type="external">Link</a> + <img src="photo.png"/> + <span lang="en-US">English</span> + <span lang="en">Plain English</span> + <span lang="fr">French</span> + <div class="empty-div"/> + </div> + <div id="sidebar" class="sidebar"> + <p class="intro">Side</p> + </div> + </root>"#, + ) + .unwrap() + } + + // --------------------------------------------------------------- + // 1. Basic element matching by tag name + // --------------------------------------------------------------- + + #[test] + fn test_tag_name_match() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "p"); + assert_eq!(result.len(), 3); // 2 in main + 1 in sidebar + for &node in &result { + assert_eq!(doc.node_name(node), Some("p")); + } + } + + #[test] + fn test_tag_name_case_insensitive() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // CSS tag matching should be case-insensitive + let result = eval(&doc, root, "P"); + assert_eq!(result.len(), 3); + } + + #[test] + fn test_tag_name_no_match() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "table"); + assert!(result.is_empty()); + } + + #[test] + fn test_tag_name_h1() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "h1"); + assert_eq!(result.len(), 1); + assert_eq!(doc.text_content(result[0]), "Title"); + } + + // --------------------------------------------------------------- + // 2. Class matching + // --------------------------------------------------------------- + + #[test] + fn test_class_single() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, ".intro"); + assert_eq!(result.len(), 2); // main p.intro + sidebar p.intro + } + + #[test] + fn test_class_multiple_on_element() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // The main div has class="container wide" — match on either individually + let result_container = eval(&doc, root, ".container"); + assert_eq!(result_container.len(), 1); + let result_wide = eval(&doc, root, ".wide"); + assert_eq!(result_wide.len(), 1); + assert_eq!(result_container[0], result_wide[0]); + } + + #[test] + fn test_class_compound_both_required() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // Require both classes on the same element + let result = eval(&doc, root, ".container.wide"); + assert_eq!(result.len(), 1); + assert_eq!(doc.node_name(result[0]), Some("div")); + } + + #[test] + fn test_class_no_match() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, ".nonexistent"); + assert!(result.is_empty()); + } + + #[test] + fn test_class_with_tag() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "p.intro"); + assert_eq!(result.len(), 2); + } + + // --------------------------------------------------------------- + // 3. ID matching + // --------------------------------------------------------------- + + #[test] + fn test_id_match() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "#main"); + assert_eq!(result.len(), 1); + assert_eq!(doc.node_name(result[0]), Some("div")); + } + + #[test] + fn test_id_no_match() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "#nonexistent"); + assert!(result.is_empty()); + } + + #[test] + fn test_id_with_tag() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "div#sidebar"); + assert_eq!(result.len(), 1); + assert_eq!(doc.attribute(result[0], "class"), Some("sidebar")); + } + + #[test] + fn test_id_multiple_ids_in_doc() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let main = eval(&doc, root, "#main"); + let sidebar = eval(&doc, root, "#sidebar"); + assert_eq!(main.len(), 1); + assert_eq!(sidebar.len(), 1); + assert_ne!(main[0], sidebar[0]); + } + + // --------------------------------------------------------------- + // 4. Attribute matching + // --------------------------------------------------------------- + + #[test] + fn test_attr_existence() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "[href]"); + assert_eq!(result.len(), 1); + assert_eq!(doc.node_name(result[0]), Some("a")); + } + + #[test] + fn test_attr_existence_no_match() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "[title]"); + assert!(result.is_empty()); + } + + #[test] + fn test_attr_exact_value() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "[data-type=\"external\"]"); + assert_eq!(result.len(), 1); + assert_eq!(doc.node_name(result[0]), Some("a")); + } + + #[test] + fn test_attr_exact_value_no_match() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "[data-type=\"internal\"]"); + assert!(result.is_empty()); + } + + #[test] + fn test_attr_prefix() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "[href^=\"https\"]"); + assert_eq!(result.len(), 1); + assert_eq!(doc.node_name(result[0]), Some("a")); + } + + #[test] + fn test_attr_prefix_no_match() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "[href^=\"ftp\"]"); + assert!(result.is_empty()); + } + + #[test] + fn test_attr_suffix() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "[src$=\".png\"]"); + assert_eq!(result.len(), 1); + assert_eq!(doc.node_name(result[0]), Some("img")); + } + + #[test] + fn test_attr_suffix_no_match() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "[src$=\".jpg\"]"); + assert!(result.is_empty()); + } + + #[test] + fn test_attr_substring() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "[href*=\"example\"]"); + assert_eq!(result.len(), 1); + assert_eq!(doc.node_name(result[0]), Some("a")); + } + + #[test] + fn test_attr_substring_no_match() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "[href*=\"missing\"]"); + assert!(result.is_empty()); + } + + #[test] + fn test_attr_word() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // class="container wide" — match the word "container" + let result = eval(&doc, root, "[class~=\"container\"]"); + assert_eq!(result.len(), 1); + assert_eq!(doc.attribute(result[0], "id"), Some("main")); + } + + #[test] + fn test_attr_dash_prefix_exact() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // lang="en" exactly matches [lang|="en"] + let result = eval(&doc, root, "[lang|=\"en\"]"); + // Should match both lang="en-US" and lang="en", but NOT lang="fr" + assert_eq!(result.len(), 2); + } + + #[test] + fn test_attr_dash_prefix_no_match() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "[lang|=\"de\"]"); + assert!(result.is_empty()); + } + + // --------------------------------------------------------------- + // 5. Pseudo-class matching + // --------------------------------------------------------------- + + #[test] + fn test_pseudo_first_child() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "li:first-child"); + assert_eq!(result.len(), 1); + assert_eq!(doc.text_content(result[0]), "One"); + } + + #[test] + fn test_pseudo_last_child() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "li:last-child"); + assert_eq!(result.len(), 1); + assert_eq!(doc.text_content(result[0]), "Three"); + } + + #[test] + fn test_pseudo_first_child_div() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // The first div child of root is #main + let result = eval(&doc, root, "div:first-child"); + assert_eq!(result.len(), 1); + assert_eq!(doc.attribute(result[0], "id"), Some("main")); + } + + #[test] + fn test_pseudo_empty() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, ":empty"); + // img and empty-div should be empty + let names: Vec<_> = result.iter().map(|&n| doc.node_name(n).unwrap()).collect(); + assert!(names.contains(&"img")); + assert!(names.contains(&"div")); // empty-div + } + + #[test] + fn test_pseudo_empty_excludes_non_empty() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, ":empty"); + // h1 has text content, should not match :empty + assert!(!result.iter().any(|&n| doc.node_name(n) == Some("h1"))); + } + + #[test] + fn test_pseudo_not_class() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "li:not(.active)"); + assert_eq!(result.len(), 2); + // Should be "Two" and "Three" + let texts: Vec<_> = result.iter().map(|&n| doc.text_content(n)).collect(); + assert!(texts.contains(&"Two".to_string())); + assert!(texts.contains(&"Three".to_string())); + } + + #[test] + fn test_pseudo_not_tag() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // All children of #main that are not <p> + let result = eval(&doc, root, "#main > :not(p)"); + assert!(!result.iter().any(|&n| doc.node_name(n) == Some("p"))); + assert!(result.len() >= 4); // h1, ul, a, img, span, span, span, div + } + + #[test] + fn test_pseudo_only_child() { + let doc = Document::parse_str( + r"<root><wrapper><only>Only child</only></wrapper><multi><a/><b/></multi></root>", + ) + .unwrap(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, ":only-child"); + assert_eq!(result.len(), 1); + assert_eq!(doc.node_name(result[0]), Some("only")); + } + + #[test] + fn test_pseudo_nth_child_specific() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // Second li + let result = eval(&doc, root, "li:nth-child(2)"); + assert_eq!(result.len(), 1); + assert_eq!(doc.text_content(result[0]), "Two"); + } + + #[test] + fn test_pseudo_nth_child_odd() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "li:nth-child(odd)"); + assert_eq!(result.len(), 2); // 1st and 3rd + assert_eq!(doc.text_content(result[0]), "One"); + assert_eq!(doc.text_content(result[1]), "Three"); + } + + #[test] + fn test_pseudo_nth_child_even() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "li:nth-child(even)"); + assert_eq!(result.len(), 1); // 2nd only + assert_eq!(doc.text_content(result[0]), "Two"); + } + + #[test] + fn test_pseudo_nth_last_child() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // :nth-last-child(1) is last child + let result = eval(&doc, root, "li:nth-last-child(1)"); + assert_eq!(result.len(), 1); + assert_eq!(doc.text_content(result[0]), "Three"); + } + + // --------------------------------------------------------------- + // 6. Combinator matching + // --------------------------------------------------------------- + + #[test] + fn test_combinator_descendant() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // All p descendants of div (any depth) + let result = eval(&doc, root, "div p"); + assert_eq!(result.len(), 3); // 2 in #main + 1 in #sidebar + } + + #[test] + fn test_combinator_descendant_deep() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // li is nested inside root > div > ul > li + let result = eval(&doc, root, "div li"); + assert_eq!(result.len(), 3); + } + + #[test] + fn test_combinator_child() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // Only direct children of #main that are <p> + let result = eval(&doc, root, "#main > p"); + assert_eq!(result.len(), 2); + } + + #[test] + fn test_combinator_child_excludes_deeper() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // li is NOT a direct child of div — it's a child of ul + let result = eval(&doc, root, "div > li"); + assert!(result.is_empty()); + } + + #[test] + fn test_combinator_adjacent_sibling() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // p immediately after h1 + let result = eval(&doc, root, "h1 + p"); + assert_eq!(result.len(), 1); + assert_eq!(doc.text_content(result[0]), "Hello"); + } + + #[test] + fn test_combinator_adjacent_sibling_no_match() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // h1 is not immediately preceded by a <p> + let result = eval(&doc, root, "p + h1"); + assert!(result.is_empty()); + } + + #[test] + fn test_combinator_general_sibling() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // All p elements that come after an h1 in the same parent + let result = eval(&doc, root, "h1 ~ p"); + assert_eq!(result.len(), 2); // both p's in #main + } + + #[test] + fn test_combinator_general_sibling_no_match() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // h1 has no preceding sibling <a> + let result = eval(&doc, root, "a ~ h1"); + assert!(result.is_empty()); + } + + #[test] + fn test_combinator_chain() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // Chain: div with class container > ul, then descendant li with class active + let result = eval(&doc, root, "div.container > ul li.active"); + assert_eq!(result.len(), 1); + assert_eq!(doc.text_content(result[0]), "One"); + } + + #[test] + fn test_combinator_three_levels() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // root > div > ul > li + let result = eval(&doc, root, "div > ul > li"); + assert_eq!(result.len(), 3); + } + + // --------------------------------------------------------------- + // 7. Universal selector matching + // --------------------------------------------------------------- + + #[test] + fn test_universal_all_elements() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "*"); + // Should match every element descendant of root + assert!(result.len() >= 14); // div, h1, p, p, ul, li, li, li, a, img, span, span, span, div, div, p + } + + #[test] + fn test_universal_direct_children() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // Direct children of #main + let result = eval(&doc, root, "#main > *"); + // h1, p, p, ul, a, img, span, span, span, empty-div + assert_eq!(result.len(), 10); + } + + #[test] + fn test_universal_with_class() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // Universal + class is equivalent to just .intro + let result_star = eval(&doc, root, "*.intro"); + let result_class = eval(&doc, root, ".intro"); + assert_eq!(result_star.len(), result_class.len()); + assert_eq!(result_star, result_class); + } + + #[test] + fn test_universal_with_pseudo() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "*:first-child"); + // First element child of each parent + assert!(result.len() >= 2); + // All returned nodes should be first element children of their parents + for &node in &result { + let parent = doc.parent(node).unwrap(); + let first = doc + .children(parent) + .find(|&c| matches!(doc.node(c).kind, NodeKind::Element { .. })) + .unwrap(); + assert_eq!(first, node); + } + } + + // --------------------------------------------------------------- + // Selector group (comma-separated) + // --------------------------------------------------------------- + + #[test] + fn test_selector_group() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "h1, img"); + assert_eq!(result.len(), 2); + let names: Vec<_> = result.iter().map(|&n| doc.node_name(n).unwrap()).collect(); + assert!(names.contains(&"h1")); + assert!(names.contains(&"img")); + } + + // --------------------------------------------------------------- + // Edge cases + // --------------------------------------------------------------- + + #[test] + fn test_empty_selector_group() { + let group = SelectorGroup { + selectors: vec![Selector { + compounds: Vec::new(), + }], + }; + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, &group); + assert!(result.is_empty()); + } + + #[test] + fn test_scope_limits_results() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // Get the sidebar div, then scope the search to it + let sidebar_nodes = eval(&doc, root, "#sidebar"); + assert_eq!(sidebar_nodes.len(), 1); + let sidebar = sidebar_nodes[0]; + // Only 1 <p> inside sidebar + let result = eval(&doc, sidebar, "p"); + assert_eq!(result.len(), 1); + assert_eq!(doc.text_content(result[0]), "Side"); + } + + #[test] + fn test_no_elements_in_scope() { + let doc = Document::parse_str("<root/>").unwrap(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "div"); + assert!(result.is_empty()); + } + + #[test] + fn test_fast_id_path_descendant_check() { + // The fast #id path should verify the node is a descendant of scope + let doc = test_doc(); + let root = doc.root_element().unwrap(); + // Get #sidebar, then search for #main from within it — should not find it + let sidebar_nodes = eval(&doc, root, "#sidebar"); + let sidebar = sidebar_nodes[0]; + let result = eval(&doc, sidebar, "#main"); + assert!(result.is_empty()); + } + + #[test] + fn test_document_order_preserved() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = eval(&doc, root, "li"); + assert_eq!(result.len(), 3); + assert_eq!(doc.text_content(result[0]), "One"); + assert_eq!(doc.text_content(result[1]), "Two"); + assert_eq!(doc.text_content(result[2]), "Three"); + } +} diff --git a/browser/vendor/xmloxide/src/css/mod.rs b/browser/vendor/xmloxide/src/css/mod.rs new file mode 100644 index 000000000..30d4ad414 --- /dev/null +++ b/browser/vendor/xmloxide/src/css/mod.rs @@ -0,0 +1,340 @@ +//! CSS selector engine for querying [`Document`] trees. +//! +//! Provides a familiar CSS selector API for finding elements in XML/HTML +//! documents, as an alternative to [`XPath`](crate::xpath). +//! +//! # Supported Selectors +//! +//! | Selector | Example | Description | +//! |----------|---------|-------------| +//! | Tag | `div` | Matches elements by tag name | +//! | Class | `.intro` | Matches elements with a class | +//! | ID | `#main` | Matches elements by id attribute | +//! | Universal | `*` | Matches any element | +//! | Attribute | `[href]` | Matches elements with an attribute | +//! | Attr value | `[type="text"]` | Exact attribute value match | +//! | Attr prefix | `[href^="https"]` | Attribute starts with value | +//! | Attr suffix | `[src$=".png"]` | Attribute ends with value | +//! | Attr substr | `[title*="hello"]` | Attribute contains value | +//! | Attr word | `[class~="active"]` | Whitespace-separated word match | +//! | Attr dash | `[lang\|="en"]` | Exact or dash-prefix match | +//! | Descendant | `div p` | `p` inside `div` (any depth) | +//! | Child | `div > p` | `p` directly inside `div` | +//! | Adjacent | `h1 + p` | `p` immediately after `h1` | +//! | General sibling | `h1 ~ p` | `p` after `h1` (same parent) | +//! | Group | `div, p` | Matches `div` or `p` | +//! | `:first-child` | `p:first-child` | First child element | +//! | `:last-child` | `p:last-child` | Last child element | +//! | `:only-child` | `p:only-child` | Only child element | +//! | `:empty` | `div:empty` | Element with no children | +//! | `:not()` | `:not(.hidden)` | Negation | +//! | `:nth-child()` | `:nth-child(2n+1)` | Position-based matching | +//! +//! # Examples +//! +//! ``` +//! use xmloxide::css::select; +//! use xmloxide::Document; +//! +//! let doc = Document::parse_str(r#" +//! <html> +//! <body> +//! <div class="content"> +//! <p id="intro">Hello</p> +//! <p class="highlight">World</p> +//! </div> +//! </body> +//! </html> +//! "#).unwrap(); +//! +//! let root = doc.root_element().unwrap(); +//! +//! // Find all paragraphs +//! let paragraphs = select(&doc, root, "p").unwrap(); +//! assert_eq!(paragraphs.len(), 2); +//! +//! // Find by class +//! let highlighted = select(&doc, root, ".highlight").unwrap(); +//! assert_eq!(highlighted.len(), 1); +//! assert_eq!(doc.text_content(highlighted[0]), "World"); +//! +//! // Find by ID +//! let intro = select(&doc, root, "#intro").unwrap(); +//! assert_eq!(intro.len(), 1); +//! +//! // Complex selector +//! let result = select(&doc, root, "div.content > p").unwrap(); +//! assert_eq!(result.len(), 2); +//! ``` + +mod eval; +pub mod parser; +pub mod types; + +pub use parser::CssSelectorError; +pub use types::SelectorGroup; + +use crate::tree::{Document, NodeId}; + +/// Select all descendant elements matching a CSS selector string. +/// +/// Parses the selector and evaluates it against all descendants of `scope`. +/// Returns matching nodes in document order. +/// +/// # Errors +/// +/// Returns a [`CssSelectorError`] if the selector string is malformed. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::css::select; +/// use xmloxide::Document; +/// +/// let doc = Document::parse_str("<ul><li class=\"a\">1</li><li>2</li></ul>").unwrap(); +/// let root = doc.root_element().unwrap(); +/// let items = select(&doc, root, "li.a").unwrap(); +/// assert_eq!(items.len(), 1); +/// ``` +pub fn select( + doc: &Document, + scope: NodeId, + selector: &str, +) -> Result<Vec<NodeId>, CssSelectorError> { + let group = parser::parse_selector(selector)?; + Ok(eval::select(doc, scope, &group)) +} + +/// Select all descendant elements matching a pre-parsed selector group. +/// +/// Use this when evaluating the same selector against multiple documents +/// or scopes to avoid re-parsing the selector string. +pub fn select_with(doc: &Document, scope: NodeId, group: &SelectorGroup) -> Vec<NodeId> { + eval::select(doc, scope, group) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn test_doc() -> Document { + Document::parse_str( + r#"<html> + <body> + <div id="main" class="container wide"> + <h1>Title</h1> + <p class="intro">Hello</p> + <p class="body">World</p> + <ul> + <li class="active">One</li> + <li>Two</li> + <li>Three</li> + </ul> + <a href="https://example.com">Link</a> + <img src="photo.png"/> + <span lang="en-US">English</span> + </div> + <div class="sidebar"> + <p>Side</p> + </div> + </body> + </html>"#, + ) + .unwrap() + } + + #[test] + fn test_select_by_tag() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let ps = select(&doc, root, "p").unwrap(); + assert_eq!(ps.len(), 3); + } + + #[test] + fn test_select_by_class() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, ".intro").unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(doc.text_content(result[0]), "Hello"); + } + + #[test] + fn test_select_by_id() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, "#main").unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(doc.node_name(result[0]), Some("div")); + } + + #[test] + fn test_select_descendant() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, "div p").unwrap(); + assert_eq!(result.len(), 3); // 2 in main + 1 in sidebar + } + + #[test] + fn test_select_child() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, "#main > p").unwrap(); + assert_eq!(result.len(), 2); + } + + #[test] + fn test_select_adjacent_sibling() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, "h1 + p").unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(doc.text_content(result[0]), "Hello"); + } + + #[test] + fn test_select_general_sibling() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, "h1 ~ p").unwrap(); + assert_eq!(result.len(), 2); + } + + #[test] + fn test_select_group() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, "h1, img").unwrap(); + assert_eq!(result.len(), 2); + } + + #[test] + fn test_select_attr_existence() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, "[href]").unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(doc.node_name(result[0]), Some("a")); + } + + #[test] + fn test_select_attr_prefix() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, "[href^=\"https\"]").unwrap(); + assert_eq!(result.len(), 1); + } + + #[test] + fn test_select_attr_suffix() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, "[src$=\".png\"]").unwrap(); + assert_eq!(result.len(), 1); + } + + #[test] + fn test_select_attr_dash_prefix() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, "[lang|=\"en\"]").unwrap(); + assert_eq!(result.len(), 1); + } + + #[test] + fn test_select_first_child() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, "li:first-child").unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(doc.text_content(result[0]), "One"); + } + + #[test] + fn test_select_last_child() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, "li:last-child").unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(doc.text_content(result[0]), "Three"); + } + + #[test] + fn test_select_not() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, "li:not(.active)").unwrap(); + assert_eq!(result.len(), 2); + } + + #[test] + fn test_select_nth_child_odd() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, "li:nth-child(odd)").unwrap(); + assert_eq!(result.len(), 2); // 1st and 3rd + } + + #[test] + fn test_select_empty() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, ":empty").unwrap(); + // img is self-closing / empty + assert!(result.iter().any(|&n| doc.node_name(n) == Some("img"))); + } + + #[test] + fn test_select_universal() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, "#main > *").unwrap(); + // All direct children of #main + assert!(result.len() >= 5); + } + + #[test] + fn test_select_multiple_classes() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, ".container.wide").unwrap(); + assert_eq!(result.len(), 1); + } + + #[test] + fn test_select_complex() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, "div.container > ul li.active").unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(doc.text_content(result[0]), "One"); + } + + #[test] + fn test_select_error() { + let doc = test_doc(); + let root = doc.root_element().unwrap(); + assert!(select(&doc, root, ">>>").is_err()); + } + + #[test] + fn test_id_map_auto_populated() { + // Verify element_by_id works without DTD validation + let doc = test_doc(); + let node = doc.element_by_id("main").unwrap(); + assert_eq!(doc.node_name(node), Some("div")); + } + + #[test] + fn test_fast_id_select() { + // Pure #id selector should use the fast path + let doc = test_doc(); + let root = doc.root_element().unwrap(); + let result = select(&doc, root, "#main").unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(doc.node_name(result[0]), Some("div")); + } +} diff --git a/browser/vendor/xmloxide/src/css/parser.rs b/browser/vendor/xmloxide/src/css/parser.rs new file mode 100644 index 000000000..0d02cc86e --- /dev/null +++ b/browser/vendor/xmloxide/src/css/parser.rs @@ -0,0 +1,580 @@ +//! CSS selector parser. +//! +//! Hand-rolled recursive descent parser that converts a CSS selector string +//! into a [`SelectorGroup`] AST. + +use super::types::{ + AttrMatcher, AttrOp, AttrSelector, Combinator, CompoundEntry, CompoundSelector, NthExpr, + PseudoClass, Selector, SelectorGroup, +}; + +/// Parse error with position information. +#[derive(Debug, Clone)] +pub struct CssSelectorError { + /// Human-readable error message. + pub message: String, + /// Byte offset in the input where the error occurred. + pub position: usize, +} + +impl std::fmt::Display for CssSelectorError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "CSS selector error at {}: {}", + self.position, self.message + ) + } +} + +impl std::error::Error for CssSelectorError {} + +/// Parse a CSS selector string into a [`SelectorGroup`]. +/// +/// # Errors +/// +/// Returns a [`CssSelectorError`] if the selector string is malformed. +pub fn parse_selector(input: &str) -> Result<SelectorGroup, CssSelectorError> { + let mut parser = Parser::new(input); + parser.parse_selector_group() +} + +struct Parser<'a> { + input: &'a str, + pos: usize, +} + +impl<'a> Parser<'a> { + fn new(input: &'a str) -> Self { + Self { input, pos: 0 } + } + + fn remaining(&self) -> &'a str { + &self.input[self.pos..] + } + + fn peek(&self) -> Option<char> { + self.remaining().chars().next() + } + + fn advance(&mut self, n: usize) { + self.pos += n; + } + + fn skip_whitespace(&mut self) { + while self.peek().is_some_and(|c| c.is_ascii_whitespace()) { + self.advance(1); + } + } + + fn at_end(&self) -> bool { + self.pos >= self.input.len() + } + + fn err(&self, msg: impl Into<String>) -> CssSelectorError { + CssSelectorError { + message: msg.into(), + position: self.pos, + } + } + + // --- Grammar --- + + fn parse_selector_group(&mut self) -> Result<SelectorGroup, CssSelectorError> { + let mut selectors = vec![self.parse_selector()?]; + loop { + self.skip_whitespace(); + if self.peek() == Some(',') { + self.advance(1); + self.skip_whitespace(); + selectors.push(self.parse_selector()?); + } else { + break; + } + } + if !self.at_end() { + return Err(self.err(format!( + "unexpected character '{}'", + self.peek().unwrap_or('?') + ))); + } + Ok(SelectorGroup { selectors }) + } + + fn parse_selector(&mut self) -> Result<Selector, CssSelectorError> { + let first = self.parse_compound()?; + let mut compounds = vec![CompoundEntry { + combinator: Combinator::None, + compound: first, + }]; + + loop { + let had_ws = self.skip_ws_and_check(); + if self.at_end() || self.peek() == Some(',') { + break; + } + + let combinator = if self.peek() == Some('>') { + self.advance(1); + self.skip_whitespace(); + Combinator::Child + } else if self.peek() == Some('+') { + self.advance(1); + self.skip_whitespace(); + Combinator::NextSibling + } else if self.peek() == Some('~') { + self.advance(1); + self.skip_whitespace(); + Combinator::SubsequentSibling + } else if had_ws { + Combinator::Descendant + } else { + break; + }; + + compounds.push(CompoundEntry { + combinator, + compound: self.parse_compound()?, + }); + } + + Ok(Selector { compounds }) + } + + /// Skip whitespace and return whether any was skipped. + fn skip_ws_and_check(&mut self) -> bool { + let before = self.pos; + self.skip_whitespace(); + self.pos > before + } + + fn parse_compound(&mut self) -> Result<CompoundSelector, CssSelectorError> { + let mut compound = CompoundSelector::default(); + let mut has_component = false; + + // Optional tag name or * + if self + .peek() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '*') + { + if self.peek() == Some('*') { + self.advance(1); + // Universal selector — tag stays None but is still a valid component + } else { + compound.tag = Some(self.parse_ident()?); + } + has_component = true; + } + + // Simple selectors: #id, .class, [attr], :pseudo + loop { + match self.peek() { + Some('#') => { + self.advance(1); + compound.id = Some(self.parse_ident()?); + has_component = true; + } + Some('.') => { + self.advance(1); + compound.classes.push(self.parse_ident()?); + has_component = true; + } + Some('[') => { + compound.attrs.push(self.parse_attr_selector()?); + has_component = true; + } + Some(':') => { + compound.pseudos.push(self.parse_pseudo_class()?); + has_component = true; + } + _ => break, + } + } + + if !has_component { + return Err(self.err("expected selector")); + } + + Ok(compound) + } + + fn parse_ident(&mut self) -> Result<String, CssSelectorError> { + let start = self.pos; + while self + .peek() + .is_some_and(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + self.advance(self.peek().unwrap_or(' ').len_utf8()); + } + if self.pos == start { + return Err(self.err("expected identifier")); + } + Ok(self.input[start..self.pos].to_string()) + } + + fn parse_attr_selector(&mut self) -> Result<AttrSelector, CssSelectorError> { + self.advance(1); // consume '[' + self.skip_whitespace(); + + let name = self.parse_ident()?; + self.skip_whitespace(); + + let matcher = if self.peek() == Some(']') { + None + } else { + let op = self.parse_attr_op()?; + self.skip_whitespace(); + let value = self.parse_attr_value()?; + self.skip_whitespace(); + let case_insensitive = if self.peek() == Some('i') || self.peek() == Some('I') { + self.advance(1); + self.skip_whitespace(); + true + } else { + false + }; + Some(AttrMatcher { + op, + value, + case_insensitive, + }) + }; + + if self.peek() != Some(']') { + return Err(self.err("expected ']'")); + } + self.advance(1); + + Ok(AttrSelector { name, matcher }) + } + + fn parse_attr_op(&mut self) -> Result<AttrOp, CssSelectorError> { + let op = match self.peek() { + Some('=') => { + self.advance(1); + AttrOp::Exact + } + Some('~') => { + self.advance(1); + self.expect_char('=')?; + AttrOp::Word + } + Some('|') => { + self.advance(1); + self.expect_char('=')?; + AttrOp::DashPrefix + } + Some('^') => { + self.advance(1); + self.expect_char('=')?; + AttrOp::Prefix + } + Some('$') => { + self.advance(1); + self.expect_char('=')?; + AttrOp::Suffix + } + Some('*') => { + self.advance(1); + self.expect_char('=')?; + AttrOp::Substring + } + _ => return Err(self.err("expected attribute operator")), + }; + Ok(op) + } + + fn parse_attr_value(&mut self) -> Result<String, CssSelectorError> { + match self.peek() { + Some(quote @ ('"' | '\'')) => { + self.advance(1); + let start = self.pos; + while self.peek().is_some_and(|c| c != quote) { + self.advance(self.peek().unwrap_or(' ').len_utf8()); + } + let value = self.input[start..self.pos].to_string(); + self.expect_char(quote)?; + Ok(value) + } + _ => self.parse_ident(), + } + } + + fn parse_pseudo_class(&mut self) -> Result<PseudoClass, CssSelectorError> { + self.advance(1); // consume ':' + let name = self.parse_ident()?; + + match name.as_str() { + "first-child" => Ok(PseudoClass::FirstChild), + "last-child" => Ok(PseudoClass::LastChild), + "only-child" => Ok(PseudoClass::OnlyChild), + "empty" => Ok(PseudoClass::Empty), + "not" => { + self.expect_char('(')?; + self.skip_whitespace(); + let inner = self.parse_compound()?; + self.skip_whitespace(); + self.expect_char(')')?; + Ok(PseudoClass::Not(Box::new(inner))) + } + "nth-child" => { + self.expect_char('(')?; + let expr = self.parse_nth_expr()?; + self.expect_char(')')?; + Ok(PseudoClass::NthChild(expr)) + } + "nth-last-child" => { + self.expect_char('(')?; + let expr = self.parse_nth_expr()?; + self.expect_char(')')?; + Ok(PseudoClass::NthLastChild(expr)) + } + _ => Err(self.err(format!("unknown pseudo-class ':{name}'"))), + } + } + + fn parse_nth_expr(&mut self) -> Result<NthExpr, CssSelectorError> { + self.skip_whitespace(); + + // Handle keywords: odd, even + if self.remaining().starts_with("odd") { + self.advance(3); + self.skip_whitespace(); + return Ok(NthExpr { a: 2, b: 1 }); + } + if self.remaining().starts_with("even") { + self.advance(4); + self.skip_whitespace(); + return Ok(NthExpr { a: 2, b: 0 }); + } + + // Parse An+B + let neg = self.peek() == Some('-'); + if neg || self.peek() == Some('+') { + self.advance(1); + } + + // Check for 'n' without leading number (means 1n or -1n) + if self.peek() == Some('n') { + self.advance(1); + let a = if neg { -1 } else { 1 }; + let b = self.parse_nth_offset()?; + self.skip_whitespace(); + return Ok(NthExpr { a, b }); + } + + // Parse number + let num = self.parse_int()?; + let num = if neg { -num } else { num }; + + if self.peek() == Some('n') { + self.advance(1); + let b = self.parse_nth_offset()?; + self.skip_whitespace(); + Ok(NthExpr { a: num, b }) + } else { + self.skip_whitespace(); + Ok(NthExpr { a: 0, b: num }) + } + } + + fn parse_nth_offset(&mut self) -> Result<i32, CssSelectorError> { + self.skip_whitespace(); + match self.peek() { + Some('+') => { + self.advance(1); + self.skip_whitespace(); + self.parse_int() + } + Some('-') => { + self.advance(1); + self.skip_whitespace(); + self.parse_int().map(|n| -n) + } + _ => Ok(0), + } + } + + fn parse_int(&mut self) -> Result<i32, CssSelectorError> { + let start = self.pos; + while self.peek().is_some_and(|c| c.is_ascii_digit()) { + self.advance(1); + } + if self.pos == start { + return Err(self.err("expected number")); + } + self.input[start..self.pos] + .parse() + .map_err(|_| self.err("invalid number")) + } + + fn expect_char(&mut self, expected: char) -> Result<(), CssSelectorError> { + if self.peek() == Some(expected) { + self.advance(expected.len_utf8()); + Ok(()) + } else { + Err(self.err(format!( + "expected '{expected}', got '{}'", + self.peek().unwrap_or('?') + ))) + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn test_tag_selector() { + let sg = parse_selector("div").unwrap(); + assert_eq!(sg.selectors.len(), 1); + let s = &sg.selectors[0]; + assert_eq!(s.compounds.len(), 1); + assert_eq!(s.compounds[0].compound.tag.as_deref(), Some("div")); + } + + #[test] + fn test_class_selector() { + let sg = parse_selector(".intro").unwrap(); + assert_eq!(sg.selectors[0].compounds[0].compound.classes, vec!["intro"]); + } + + #[test] + fn test_id_selector() { + let sg = parse_selector("#main").unwrap(); + assert_eq!( + sg.selectors[0].compounds[0].compound.id.as_deref(), + Some("main") + ); + } + + #[test] + fn test_compound_selector() { + let sg = parse_selector("div.intro#first").unwrap(); + let c = &sg.selectors[0].compounds[0].compound; + assert_eq!(c.tag.as_deref(), Some("div")); + assert_eq!(c.classes, vec!["intro"]); + assert_eq!(c.id.as_deref(), Some("first")); + } + + #[test] + fn test_descendant_combinator() { + let sg = parse_selector("div p").unwrap(); + assert_eq!(sg.selectors[0].compounds.len(), 2); + assert_eq!( + sg.selectors[0].compounds[1].combinator, + Combinator::Descendant + ); + } + + #[test] + fn test_child_combinator() { + let sg = parse_selector("div > p").unwrap(); + assert_eq!(sg.selectors[0].compounds[1].combinator, Combinator::Child); + } + + #[test] + fn test_sibling_combinators() { + let sg = parse_selector("div + p").unwrap(); + assert_eq!( + sg.selectors[0].compounds[1].combinator, + Combinator::NextSibling + ); + + let sg = parse_selector("div ~ p").unwrap(); + assert_eq!( + sg.selectors[0].compounds[1].combinator, + Combinator::SubsequentSibling + ); + } + + #[test] + fn test_selector_group() { + let sg = parse_selector("div, p, span").unwrap(); + assert_eq!(sg.selectors.len(), 3); + } + + #[test] + fn test_attr_existence() { + let sg = parse_selector("[href]").unwrap(); + let attr = &sg.selectors[0].compounds[0].compound.attrs[0]; + assert_eq!(attr.name, "href"); + assert!(attr.matcher.is_none()); + } + + #[test] + fn test_attr_exact() { + let sg = parse_selector("[type=\"text\"]").unwrap(); + let attr = &sg.selectors[0].compounds[0].compound.attrs[0]; + assert_eq!(attr.name, "type"); + let m = attr.matcher.as_ref().unwrap(); + assert_eq!(m.op, AttrOp::Exact); + assert_eq!(m.value, "text"); + } + + #[test] + fn test_attr_prefix() { + let sg = parse_selector("[href^=\"https\"]").unwrap(); + let m = sg.selectors[0].compounds[0].compound.attrs[0] + .matcher + .as_ref() + .unwrap(); + assert_eq!(m.op, AttrOp::Prefix); + assert_eq!(m.value, "https"); + } + + #[test] + fn test_pseudo_first_child() { + let sg = parse_selector("p:first-child").unwrap(); + assert!(matches!( + sg.selectors[0].compounds[0].compound.pseudos[0], + PseudoClass::FirstChild + )); + } + + #[test] + fn test_pseudo_not() { + let sg = parse_selector(":not(.hidden)").unwrap(); + if let PseudoClass::Not(inner) = &sg.selectors[0].compounds[0].compound.pseudos[0] { + assert_eq!(inner.classes, vec!["hidden"]); + } else { + panic!("expected :not()"); + } + } + + #[test] + fn test_pseudo_nth_child() { + let sg = parse_selector(":nth-child(2n+1)").unwrap(); + if let PseudoClass::NthChild(expr) = &sg.selectors[0].compounds[0].compound.pseudos[0] { + assert_eq!(expr.a, 2); + assert_eq!(expr.b, 1); + } else { + panic!("expected :nth-child()"); + } + } + + #[test] + fn test_pseudo_nth_child_odd() { + let sg = parse_selector(":nth-child(odd)").unwrap(); + if let PseudoClass::NthChild(expr) = &sg.selectors[0].compounds[0].compound.pseudos[0] { + assert_eq!(expr.a, 2); + assert_eq!(expr.b, 1); + } else { + panic!("expected :nth-child()"); + } + } + + #[test] + fn test_universal_selector() { + let sg = parse_selector("*").unwrap(); + assert!(sg.selectors[0].compounds[0].compound.tag.is_none()); + } + + #[test] + fn test_complex_selector() { + let sg = parse_selector("div.container > ul.nav li.active a[href]").unwrap(); + assert_eq!(sg.selectors[0].compounds.len(), 4); + } +} diff --git a/browser/vendor/xmloxide/src/css/types.rs b/browser/vendor/xmloxide/src/css/types.rs new file mode 100644 index 000000000..0492adef5 --- /dev/null +++ b/browser/vendor/xmloxide/src/css/types.rs @@ -0,0 +1,138 @@ +//! CSS selector AST types. + +/// A group of selectors separated by commas: `div, p.intro` +#[derive(Debug, Clone)] +pub struct SelectorGroup { + /// Individual selectors in the group. + pub selectors: Vec<Selector>, +} + +/// A single selector: a chain of compound selectors joined by combinators. +/// +/// For example, `div > p.intro` is a chain of two compounds: +/// `div` (followed by child combinator) and `p.intro`. +#[derive(Debug, Clone)] +pub struct Selector { + /// The chain of compound selectors and combinators. + pub compounds: Vec<CompoundEntry>, +} + +/// An entry in the selector chain: a compound selector with its leading combinator. +#[derive(Debug, Clone)] +pub struct CompoundEntry { + /// How this compound relates to the previous one. + /// The first entry in a chain uses `Combinator::None`. + pub combinator: Combinator, + /// The compound selector itself. + pub compound: CompoundSelector, +} + +/// A compound selector: a set of simple selectors that all apply to the same element. +/// +/// For example, `p.intro#first[lang]` has tag=`p`, classes=\[`intro`\], +/// id=`first`, and attrs=\[`lang`\]. +#[derive(Debug, Clone, Default)] +pub struct CompoundSelector { + /// Tag name matcher (e.g., `div`). `None` means any tag (implicit `*`). + pub tag: Option<String>, + /// ID matcher (e.g., `#main`). + pub id: Option<String>, + /// Class matchers (e.g., `.intro`). + pub classes: Vec<String>, + /// Attribute matchers (e.g., `[href^="https"]`). + pub attrs: Vec<AttrSelector>, + /// Pseudo-class matchers (e.g., `:first-child`). + pub pseudos: Vec<PseudoClass>, +} + +/// Combinator between compound selectors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Combinator { + /// No combinator (first in chain). + None, + /// Descendant combinator (whitespace): `div p` + Descendant, + /// Child combinator: `div > p` + Child, + /// Adjacent sibling combinator: `div + p` + NextSibling, + /// General sibling combinator: `div ~ p` + SubsequentSibling, +} + +/// An attribute selector: `[attr]`, `[attr=value]`, `[attr^=value]`, etc. +#[derive(Debug, Clone)] +pub struct AttrSelector { + /// Attribute name. + pub name: String, + /// Match operator and value. `None` means just `[attr]` (existence check). + pub matcher: Option<AttrMatcher>, +} + +/// Attribute value matching operator and value. +#[derive(Debug, Clone)] +pub struct AttrMatcher { + /// The match operator. + pub op: AttrOp, + /// The value to match against. + pub value: String, + /// Case-insensitive flag (`i` modifier). + pub case_insensitive: bool, +} + +/// Attribute match operators. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AttrOp { + /// `=` — exact match + Exact, + /// `~=` — whitespace-separated word match + Word, + /// `|=` — exact or prefix followed by `-` + DashPrefix, + /// `^=` — starts with + Prefix, + /// `$=` — ends with + Suffix, + /// `*=` — contains substring + Substring, +} + +/// Pseudo-class selectors. +#[derive(Debug, Clone)] +pub enum PseudoClass { + /// `:first-child` + FirstChild, + /// `:last-child` + LastChild, + /// `:only-child` + OnlyChild, + /// `:empty` + Empty, + /// `:not(selector)` + Not(Box<CompoundSelector>), + /// `:nth-child(An+B)` + NthChild(NthExpr), + /// `:nth-last-child(An+B)` + NthLastChild(NthExpr), +} + +/// An `An+B` expression for `:nth-child()` and similar. +#[derive(Debug, Clone, Copy)] +pub struct NthExpr { + /// The `A` coefficient (0 for just `B`). + pub a: i32, + /// The `B` offset. + pub b: i32, +} + +impl NthExpr { + /// Returns true if the 1-based position `pos` matches this `An+B` expression. + pub fn matches(&self, pos: i32) -> bool { + if self.a == 0 { + return pos == self.b; + } + let diff = pos - self.b; + // diff must be divisible by a and have the same sign + diff % self.a == 0 && diff / self.a >= 0 + } +} diff --git a/browser/vendor/xmloxide/src/encoding/mod.rs b/browser/vendor/xmloxide/src/encoding/mod.rs new file mode 100644 index 000000000..55bc88a79 --- /dev/null +++ b/browser/vendor/xmloxide/src/encoding/mod.rs @@ -0,0 +1,451 @@ +//! Encoding detection and transcoding. +//! +//! Implements BOM sniffing and XML declaration encoding detection per +//! XML 1.0 Section 4.3.3 and Appendix F, bridging to `encoding_rs` for character +//! encoding conversion. +//! +//! # Encoding Detection Strategy +//! +//! 1. Check for a Byte Order Mark (BOM) at the start of the input. +//! 2. If a BOM is found, use the indicated encoding and skip the BOM bytes. +//! 3. If no BOM is found, default to UTF-8 (per the XML specification). +//! 4. After initial decoding, inspect the XML declaration's `encoding=` attribute +//! to confirm or override the detected encoding. + +use std::fmt; + +/// An error that occurs during encoding detection or transcoding. +#[derive(Debug, Clone)] +pub struct EncodingError { + /// A human-readable description of the encoding error. + pub message: String, +} + +impl EncodingError { + /// Creates a new `EncodingError` with the given message. + fn new(message: impl Into<String>) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for EncodingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "encoding error: {}", self.message) + } +} + +impl std::error::Error for EncodingError {} + +/// Detects the encoding of an XML byte stream by inspecting the Byte Order Mark. +/// +/// Returns a tuple of (encoding name, number of BOM bytes to skip). The encoding +/// name is an IANA charset name suitable for passing to `encoding_rs`. +/// +/// Per XML 1.0 Appendix F, the BOM detection order is: +/// - `EF BB BF` -> UTF-8 +/// - `FE FF` -> UTF-16 BE +/// - `FF FE` -> UTF-16 LE +/// - No BOM -> UTF-8 (default per XML spec) +/// +/// # Examples +/// +/// ``` +/// use xmloxide::encoding::detect_encoding; +/// +/// let (enc, skip) = detect_encoding(b"\xEF\xBB\xBFhello"); +/// assert_eq!(enc, "UTF-8"); +/// assert_eq!(skip, 3); +/// +/// let (enc, skip) = detect_encoding(b"<root/>"); +/// assert_eq!(enc, "UTF-8"); +/// assert_eq!(skip, 0); +/// ``` +#[must_use] +pub fn detect_encoding(bytes: &[u8]) -> (&'static str, usize) { + if bytes.len() >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF { + ("UTF-8", 3) + } else if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF { + ("UTF-16BE", 2) + } else if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE { + ("UTF-16LE", 2) + } else { + ("UTF-8", 0) + } +} + +/// Transcodes a byte slice from the named encoding into a UTF-8 `String`. +/// +/// Uses `encoding_rs::Encoding::for_label` to look up the encoding by its IANA +/// name (case-insensitive). Returns an error if the encoding is unknown or if +/// the input contains malformed byte sequences. +/// +/// # Errors +/// +/// Returns `EncodingError` if the encoding name is not recognized or if +/// transcoding fails due to malformed input bytes. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::encoding::transcode; +/// +/// let result = transcode(b"hello", "UTF-8").unwrap(); +/// assert_eq!(result, "hello"); +/// ``` +pub fn transcode(bytes: &[u8], encoding_name: &str) -> Result<String, EncodingError> { + let encoding = encoding_rs::Encoding::for_label(encoding_name.as_bytes()) + .ok_or_else(|| EncodingError::new(format!("unsupported encoding: {encoding_name}")))?; + + let (result, _used_encoding, had_errors) = encoding.decode(bytes); + if had_errors { + return Err(EncodingError::new(format!( + "malformed byte sequence for encoding {encoding_name}" + ))); + } + Ok(result.into_owned()) +} + +/// Extracts the `encoding` attribute value from an XML declaration in the given string. +/// +/// This performs a lightweight scan of the first line to find a pattern like +/// `encoding="..."` or `encoding='...'` without running the full XML parser. +/// Returns `None` if no XML declaration or no encoding attribute is found. +fn extract_xml_decl_encoding(text: &str) -> Option<String> { + // Only look at the beginning of the document, up to the end of the XML decl. + let decl_end = text.find("?>")?; + let decl = &text[..decl_end]; + + // Must start with <?xml to be a valid XML declaration + if !decl.starts_with("<?xml") { + return None; + } + + let enc_pos = decl.find("encoding")?; + let after_enc = &decl[enc_pos + "encoding".len()..]; + + // Skip whitespace and '=' + let after_enc = after_enc.trim_start(); + let after_enc = after_enc.strip_prefix('=')?; + let after_enc = after_enc.trim_start(); + + // Extract the quoted value + let quote = after_enc.as_bytes().first().copied()?; + if quote != b'"' && quote != b'\'' { + return None; + } + let after_quote = &after_enc[1..]; + let end = after_quote.find(quote as char)?; + Some(after_quote[..end].to_string()) +} + +/// Decodes raw XML bytes into a UTF-8 string, automatically detecting the encoding. +/// +/// This implements the full encoding detection pipeline from XML 1.0 Section 4.3.3: +/// +/// 1. Detect the BOM and determine the initial encoding. +/// 2. If the encoding is UTF-8, validate and return the bytes as a string. +/// 3. If non-UTF-8, transcode using `encoding_rs`. +/// 4. After the initial decode, check the XML declaration's `encoding=` attribute. +/// If it specifies a different encoding than what the BOM indicated, re-decode +/// from the original bytes using the declared encoding. +/// +/// # Errors +/// +/// Returns `EncodingError` if the bytes contain invalid sequences for the +/// detected encoding or if the declared encoding is unsupported. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::encoding::decode_to_utf8; +/// +/// let xml = b"<?xml version=\"1.0\"?><root/>"; +/// let result = decode_to_utf8(xml).unwrap(); +/// assert!(result.contains("<root/>")); +/// ``` +pub fn decode_to_utf8(bytes: &[u8]) -> Result<String, EncodingError> { + let (bom_encoding, bom_skip) = detect_encoding(bytes); + let content_bytes = &bytes[bom_skip..]; + + // Fast path: if the BOM says UTF-8 (or no BOM, which defaults to UTF-8), + // try to validate directly without transcoding. + if bom_encoding == "UTF-8" { + if let Ok(s) = std::str::from_utf8(content_bytes) { + // Valid UTF-8. Check for an encoding declaration that might + // indicate a different encoding (unusual but permitted). + if let Some(declared) = extract_xml_decl_encoding(s) { + let declared_upper = declared.to_ascii_uppercase(); + if !is_utf8_label(&declared_upper) { + return transcode(content_bytes, &declared); + } + } + return Ok(s.to_string()); + } + // Not valid UTF-8 and no BOM. The XML declaration is required + // to be in ASCII-compatible bytes, so try to extract the + // encoding= attribute from the raw bytes interpreted as ASCII. + // If found, transcode with the declared encoding; otherwise, + // the input is genuinely malformed UTF-8. + if let Some(declared) = extract_encoding_from_ascii_bytes(content_bytes) { + return transcode(content_bytes, &declared); + } + return Err(EncodingError::new("input is not valid UTF-8")); + } + + // Non-UTF-8 BOM encoding: transcode first, then check for a declaration. + let initial_text = transcode(content_bytes, bom_encoding)?; + + if let Some(declared_encoding) = extract_xml_decl_encoding(&initial_text) { + let declared_upper = declared_encoding.to_ascii_uppercase(); + let bom_upper = bom_encoding.to_ascii_uppercase(); + + let effectively_same = declared_upper == bom_upper + || (is_utf8_label(&declared_upper) && is_utf8_label(&bom_upper)) + // "UTF-16" is compatible with both "UTF-16BE" and "UTF-16LE" — + // the BOM determines the actual byte order. + || (declared_upper == "UTF-16" + && (bom_upper == "UTF-16BE" || bom_upper == "UTF-16LE")); + + if !effectively_same { + return transcode(content_bytes, &declared_encoding); + } + } + + Ok(initial_text) +} + +/// Extracts the `encoding` attribute from raw bytes by treating them as ASCII. +/// +/// This is used as a fallback when the input is not valid UTF-8 and has no BOM. +/// Since the XML declaration must be in ASCII-compatible characters, we can scan +/// the bytes directly. Returns `None` if no encoding declaration is found. +fn extract_encoding_from_ascii_bytes(bytes: &[u8]) -> Option<String> { + // Only scan up to a reasonable limit for the XML declaration (first 200 bytes). + let limit = bytes.len().min(200); + let scan = &bytes[..limit]; + + // Look for "<?xml" at the start + if !scan.starts_with(b"<?xml") { + return None; + } + + // Find "?>" to delimit the declaration + let decl_end = scan.windows(2).position(|w| w == b"?>")?; + let decl = &scan[..decl_end]; + + // Find "encoding" within the declaration + let enc_needle = b"encoding"; + let enc_pos = decl + .windows(enc_needle.len()) + .position(|w| w == enc_needle)?; + let after_enc = &decl[enc_pos + enc_needle.len()..]; + + // Skip whitespace and '=' + let after_enc = skip_ascii_whitespace(after_enc); + if after_enc.first() != Some(&b'=') { + return None; + } + let after_eq = skip_ascii_whitespace(&after_enc[1..]); + + // Extract quoted value + let quote = *after_eq.first()?; + if quote != b'"' && quote != b'\'' { + return None; + } + let after_quote = &after_eq[1..]; + let end = after_quote.iter().position(|&b| b == quote)?; + let encoding_bytes = &after_quote[..end]; + + // The encoding name must be ASCII + if encoding_bytes.iter().all(u8::is_ascii) { + Some(String::from_utf8_lossy(encoding_bytes).into_owned()) + } else { + None + } +} + +/// Skips leading ASCII whitespace bytes (space, tab, CR, LF). +fn skip_ascii_whitespace(bytes: &[u8]) -> &[u8] { + let skip = bytes + .iter() + .take_while(|&&b| b == b' ' || b == b'\t' || b == b'\r' || b == b'\n') + .count(); + &bytes[skip..] +} + +/// Returns `true` if the label is a recognized alias for UTF-8. +fn is_utf8_label(label: &str) -> bool { + matches!(label, "UTF-8" | "UTF8") +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn test_detect_utf8_bom() { + let bytes = b"\xEF\xBB\xBF<?xml version=\"1.0\"?><root/>"; + let (encoding, skip) = detect_encoding(bytes); + assert_eq!(encoding, "UTF-8"); + assert_eq!(skip, 3); + } + + #[test] + fn test_detect_utf16le_bom() { + let bytes = b"\xFF\xFE<\x00r\x00o\x00o\x00t\x00"; + let (encoding, skip) = detect_encoding(bytes); + assert_eq!(encoding, "UTF-16LE"); + assert_eq!(skip, 2); + } + + #[test] + fn test_detect_utf16be_bom() { + let bytes = b"\xFE\xFF\x00<\x00r\x00o\x00o\x00t"; + let (encoding, skip) = detect_encoding(bytes); + assert_eq!(encoding, "UTF-16BE"); + assert_eq!(skip, 2); + } + + #[test] + fn test_detect_no_bom() { + let bytes = b"<?xml version=\"1.0\"?><root/>"; + let (encoding, skip) = detect_encoding(bytes); + assert_eq!(encoding, "UTF-8"); + assert_eq!(skip, 0); + } + + #[test] + fn test_detect_empty_input() { + let (encoding, skip) = detect_encoding(b""); + assert_eq!(encoding, "UTF-8"); + assert_eq!(skip, 0); + } + + #[test] + fn test_detect_single_byte() { + let (encoding, skip) = detect_encoding(b"\xEF"); + assert_eq!(encoding, "UTF-8"); + assert_eq!(skip, 0); + } + + #[test] + fn test_decode_utf8() { + let bytes = b"<?xml version=\"1.0\"?><root>hello</root>"; + let result = decode_to_utf8(bytes).unwrap(); + assert_eq!(result, "<?xml version=\"1.0\"?><root>hello</root>"); + } + + #[test] + fn test_decode_utf8_with_bom() { + let bytes = b"\xEF\xBB\xBF<?xml version=\"1.0\"?><root/>"; + let result = decode_to_utf8(bytes).unwrap(); + assert_eq!(result, "<?xml version=\"1.0\"?><root/>"); + } + + #[test] + fn test_decode_latin1() { + // ISO-8859-1 encoded XML with encoding declaration. + // The byte 0xE9 is 'e' with acute accent in ISO-8859-1. + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?>"); + bytes.extend_from_slice(b"<root>caf\xE9</root>"); + + let result = decode_to_utf8(&bytes).unwrap(); + assert!(result.contains("caf\u{00E9}")); + assert!(result.contains("<root>")); + } + + #[test] + fn test_transcode_utf8() { + let result = transcode(b"hello world", "UTF-8").unwrap(); + assert_eq!(result, "hello world"); + } + + #[test] + fn test_transcode_latin1() { + // 0xE9 = 'e with acute' in ISO-8859-1 + let result = transcode(b"caf\xE9", "ISO-8859-1").unwrap(); + assert_eq!(result, "caf\u{00E9}"); + } + + #[test] + fn test_transcode_unknown_encoding() { + let result = transcode(b"hello", "UNKNOWN-ENCODING-42"); + assert!(result.is_err()); + assert!(result.unwrap_err().message.contains("unsupported encoding")); + } + + #[test] + fn test_extract_xml_decl_encoding_present() { + let text = "<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?><root/>"; + let enc = extract_xml_decl_encoding(text); + assert_eq!(enc, Some("ISO-8859-1".to_string())); + } + + #[test] + fn test_extract_xml_decl_encoding_single_quotes() { + let text = "<?xml version='1.0' encoding='UTF-8'?><root/>"; + let enc = extract_xml_decl_encoding(text); + assert_eq!(enc, Some("UTF-8".to_string())); + } + + #[test] + fn test_extract_xml_decl_encoding_absent() { + let text = "<?xml version=\"1.0\"?><root/>"; + let enc = extract_xml_decl_encoding(text); + assert_eq!(enc, None); + } + + #[test] + fn test_extract_xml_decl_no_declaration() { + let text = "<root/>"; + let enc = extract_xml_decl_encoding(text); + assert_eq!(enc, None); + } + + #[test] + fn test_encoding_error_display() { + let err = EncodingError::new("test error"); + assert_eq!(err.to_string(), "encoding error: test error"); + } + + #[test] + fn test_encoding_error_is_error_trait() { + let err = EncodingError::new("test"); + let _: &dyn std::error::Error = &err; + } + + #[test] + fn test_decode_invalid_utf8() { + // 0xFF 0xFE at the start is a UTF-16LE BOM, so use a different invalid sequence. + // Bytes that are invalid UTF-8 without matching any BOM pattern. + let bytes: &[u8] = &[0x80, 0x81, 0x82]; + let result = decode_to_utf8(bytes); + assert!(result.is_err()); + } + + #[test] + fn test_parse_bytes_utf8() { + use crate::tree::Document; + + let result = Document::parse_bytes(b"<root/>"); + assert!(result.is_ok()); + let doc = result.unwrap(); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_name(root), Some("root")); + } + + #[test] + fn test_parse_bytes_utf8_with_bom() { + use crate::tree::Document; + + let mut bytes = vec![0xEF, 0xBB, 0xBF]; + bytes.extend_from_slice(b"<root/>"); + let doc = Document::parse_bytes(&bytes).unwrap(); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_name(root), Some("root")); + } +} diff --git a/browser/vendor/xmloxide/src/error/mod.rs b/browser/vendor/xmloxide/src/error/mod.rs new file mode 100644 index 000000000..7d28b25eb --- /dev/null +++ b/browser/vendor/xmloxide/src/error/mod.rs @@ -0,0 +1,159 @@ +//! Error types and diagnostics for XML parsing. +//! +//! This module provides structured error reporting with source location tracking, +//! matching libxml2's error reporting model. Errors carry line, column, and byte +//! offset information for precise diagnostics. +//! +//! The parser supports **error recovery mode**: it collects errors into a +//! `Vec<ParseDiagnostic>` while still producing a (possibly partial) tree. + +use std::fmt; + +/// Severity level for a parse diagnostic, matching libxml2's `xmlErrorLevel`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ErrorSeverity { + /// A non-fatal issue that doesn't prevent parsing. + Warning, + /// A recoverable error — the parser can continue but the document is malformed. + Error, + /// An unrecoverable error — parsing must stop. + Fatal, +} + +impl fmt::Display for ErrorSeverity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Warning => write!(f, "warning"), + Self::Error => write!(f, "error"), + Self::Fatal => write!(f, "fatal error"), + } + } +} + +/// Source location within an XML document. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct SourceLocation { + /// 1-based line number. + pub line: u32, + /// 1-based column number (in characters, not bytes). + pub column: u32, + /// 0-based byte offset from the start of the input. + pub byte_offset: usize, +} + +impl fmt::Display for SourceLocation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}", self.line, self.column) + } +} + +/// A single diagnostic emitted during parsing. +/// +/// Diagnostics are collected when the parser operates in recovery mode, +/// allowing it to produce a partial tree even when the input is malformed. +#[derive(Debug, Clone)] +pub struct ParseDiagnostic { + /// The severity of this diagnostic. + pub severity: ErrorSeverity, + /// Human-readable error message. + pub message: String, + /// Where in the source this error occurred. + pub location: SourceLocation, +} + +impl fmt::Display for ParseDiagnostic { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}: {} at {}", + self.severity, self.message, self.location + ) + } +} + +/// The error type returned when XML parsing fails. +#[derive(Debug, Clone)] +pub struct ParseError { + /// The primary error message. + pub message: String, + /// Where in the source the fatal error occurred. + pub location: SourceLocation, + /// All diagnostics collected before the fatal error (in recovery mode, + /// this includes warnings and recovered errors). + pub diagnostics: Vec<ParseDiagnostic>, +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "parse error at {}: {}", self.location, self.message) + } +} + +impl std::error::Error for ParseError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_source_location_display() { + let loc = SourceLocation { + line: 10, + column: 5, + byte_offset: 42, + }; + assert_eq!(loc.to_string(), "10:5"); + } + + #[test] + fn test_parse_error_display() { + let err = ParseError { + message: "unexpected end of input".to_string(), + location: SourceLocation { + line: 1, + column: 15, + byte_offset: 14, + }, + diagnostics: vec![], + }; + assert_eq!( + err.to_string(), + "parse error at 1:15: unexpected end of input" + ); + } + + #[test] + fn test_parse_diagnostic_display() { + let diag = ParseDiagnostic { + severity: ErrorSeverity::Warning, + message: "attribute value not quoted".to_string(), + location: SourceLocation { + line: 3, + column: 10, + byte_offset: 50, + }, + }; + assert_eq!( + diag.to_string(), + "warning: attribute value not quoted at 3:10" + ); + } + + #[test] + fn test_error_severity_display() { + assert_eq!(ErrorSeverity::Warning.to_string(), "warning"); + assert_eq!(ErrorSeverity::Error.to_string(), "error"); + assert_eq!(ErrorSeverity::Fatal.to_string(), "fatal error"); + } + + #[test] + fn test_parse_error_is_error_trait() { + let err = ParseError { + message: "test".to_string(), + location: SourceLocation::default(), + diagnostics: vec![], + }; + // Verify it implements std::error::Error + let _: &dyn std::error::Error = &err; + } +} diff --git a/browser/vendor/xmloxide/src/ffi/c14n.rs b/browser/vendor/xmloxide/src/ffi/c14n.rs new file mode 100644 index 000000000..9247f6355 --- /dev/null +++ b/browser/vendor/xmloxide/src/ffi/c14n.rs @@ -0,0 +1,99 @@ +//! Canonical XML (C14N) serialization FFI functions. +#![allow(unsafe_code, clippy::missing_safety_doc)] + +use std::os::raw::c_char; + +use crate::serial::c14n::{self, C14nOptions}; +use crate::tree::{Document, NodeId}; + +use super::strings::to_c_string; +use super::{clear_last_error, set_last_error}; + +/// Canonicalizes a document using inclusive C14N with comments. +/// +/// Returns a caller-owned C string that must be freed with +/// `xmloxide_free_string`. Returns null on failure. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_canonicalize(doc: *const Document) -> *mut c_char { + clear_last_error(); + if doc.is_null() { + set_last_error("null document pointer"); + return std::ptr::null_mut(); + } + // SAFETY: Null check above. + let doc = unsafe { &*doc }; + let output = c14n::canonicalize(doc, &C14nOptions::default()); + to_c_string(&output) +} + +/// Canonicalizes a document with options. +/// +/// `with_comments`: 1 to include comments, 0 to strip. +/// `exclusive`: 1 for exclusive C14N, 0 for inclusive. +/// +/// Returns a caller-owned C string that must be freed with +/// `xmloxide_free_string`. Returns null on failure. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_canonicalize_opts( + doc: *const Document, + with_comments: i32, + exclusive: i32, +) -> *mut c_char { + clear_last_error(); + if doc.is_null() { + set_last_error("null document pointer"); + return std::ptr::null_mut(); + } + // SAFETY: Null check above. + let doc = unsafe { &*doc }; + let opts = C14nOptions { + with_comments: with_comments != 0, + exclusive: exclusive != 0, + inclusive_prefixes: vec![], + }; + let output = c14n::canonicalize(doc, &opts); + to_c_string(&output) +} + +/// Canonicalizes a subtree rooted at the given node. +/// +/// Returns a caller-owned C string that must be freed with +/// `xmloxide_free_string`. Returns null on failure. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_canonicalize_subtree( + doc: *const Document, + node: u32, + with_comments: i32, + exclusive: i32, +) -> *mut c_char { + clear_last_error(); + if doc.is_null() { + set_last_error("null document pointer"); + return std::ptr::null_mut(); + } + let Some(node_id) = NodeId::from_raw(node) else { + set_last_error("invalid node id"); + return std::ptr::null_mut(); + }; + // SAFETY: Null check above. + let doc = unsafe { &*doc }; + let opts = C14nOptions { + with_comments: with_comments != 0, + exclusive: exclusive != 0, + inclusive_prefixes: vec![], + }; + let output = c14n::canonicalize_subtree(doc, node_id, &opts); + to_c_string(&output) +} diff --git a/browser/vendor/xmloxide/src/ffi/catalog.rs b/browser/vendor/xmloxide/src/ffi/catalog.rs new file mode 100644 index 000000000..bb863f969 --- /dev/null +++ b/browser/vendor/xmloxide/src/ffi/catalog.rs @@ -0,0 +1,144 @@ +//! XML Catalog FFI functions. +#![allow(unsafe_code, clippy::missing_safety_doc)] + +use std::ffi::CStr; +use std::os::raw::c_char; + +use crate::catalog::Catalog; + +use super::strings::to_c_string; +use super::{clear_last_error, set_last_error}; + +/// Parses an XML Catalog from a null-terminated UTF-8 XML string. +/// +/// Returns a pointer to the catalog on success, or null on failure. +/// The returned catalog must be freed with [`xmloxide_free_catalog`]. +/// +/// # Safety +/// +/// `input` must be a valid null-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_parse_catalog(input: *const c_char) -> *mut Catalog { + clear_last_error(); + if input.is_null() { + set_last_error("null input pointer"); + return std::ptr::null_mut(); + } + // SAFETY: Null check above. + let c_str = unsafe { CStr::from_ptr(input) }; + let Ok(s) = c_str.to_str() else { + set_last_error("invalid UTF-8"); + return std::ptr::null_mut(); + }; + match Catalog::parse(s) { + Ok(cat) => Box::into_raw(Box::new(cat)), + Err(e) => { + set_last_error(&e.message); + std::ptr::null_mut() + } + } +} + +/// Frees a catalog previously returned by `xmloxide_parse_catalog`. +/// +/// Passing null is safe and does nothing. +/// +/// # Safety +/// +/// `catalog` must have been returned by `xmloxide_parse_catalog`, or be null. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_free_catalog(catalog: *mut Catalog) { + if !catalog.is_null() { + // SAFETY: `catalog` was created by `Box::into_raw`, and is non-null. + unsafe { + drop(Box::from_raw(catalog)); + } + } +} + +/// Resolves a system identifier using the catalog. +/// +/// Returns a caller-owned C string with the resolved URI, or null if not found. +/// The returned string must be freed with `xmloxide_free_string`. +/// +/// # Safety +/// +/// `catalog` must be a valid catalog pointer. `system_id` must be a valid +/// null-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_catalog_resolve_system( + catalog: *const Catalog, + system_id: *const c_char, +) -> *mut c_char { + if catalog.is_null() || system_id.is_null() { + return std::ptr::null_mut(); + } + // SAFETY: Null checks above. + let catalog = unsafe { &*catalog }; + let c_id = unsafe { CStr::from_ptr(system_id) }; + let Ok(id_str) = c_id.to_str() else { + return std::ptr::null_mut(); + }; + match catalog.resolve_system(id_str) { + Some(resolved) => to_c_string(&resolved), + None => std::ptr::null_mut(), + } +} + +/// Resolves a public identifier using the catalog. +/// +/// Returns a caller-owned C string with the resolved URI, or null if not found. +/// The returned string must be freed with `xmloxide_free_string`. +/// +/// # Safety +/// +/// `catalog` must be a valid catalog pointer. `public_id` must be a valid +/// null-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_catalog_resolve_public( + catalog: *const Catalog, + public_id: *const c_char, +) -> *mut c_char { + if catalog.is_null() || public_id.is_null() { + return std::ptr::null_mut(); + } + // SAFETY: Null checks above. + let catalog = unsafe { &*catalog }; + let c_id = unsafe { CStr::from_ptr(public_id) }; + let Ok(id_str) = c_id.to_str() else { + return std::ptr::null_mut(); + }; + match catalog.resolve_public(id_str) { + Some(resolved) => to_c_string(&resolved), + None => std::ptr::null_mut(), + } +} + +/// Resolves a URI using the catalog. +/// +/// Returns a caller-owned C string with the resolved URI, or null if not found. +/// The returned string must be freed with `xmloxide_free_string`. +/// +/// # Safety +/// +/// `catalog` must be a valid catalog pointer. `uri` must be a valid +/// null-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_catalog_resolve_uri( + catalog: *const Catalog, + uri: *const c_char, +) -> *mut c_char { + if catalog.is_null() || uri.is_null() { + return std::ptr::null_mut(); + } + // SAFETY: Null checks above. + let catalog = unsafe { &*catalog }; + let c_uri = unsafe { CStr::from_ptr(uri) }; + let Ok(uri_str) = c_uri.to_str() else { + return std::ptr::null_mut(); + }; + match catalog.resolve_uri(uri_str) { + Some(resolved) => to_c_string(&resolved), + None => std::ptr::null_mut(), + } +} diff --git a/browser/vendor/xmloxide/src/ffi/css.rs b/browser/vendor/xmloxide/src/ffi/css.rs new file mode 100644 index 000000000..66ded298e --- /dev/null +++ b/browser/vendor/xmloxide/src/ffi/css.rs @@ -0,0 +1,133 @@ +//! CSS selector FFI functions. +#![allow(unsafe_code, clippy::missing_safety_doc)] + +use std::ffi::CStr; +use std::os::raw::c_char; + +use crate::css; +use crate::tree::Document; + +use super::{clear_last_error, set_last_error}; + +/// Evaluates a CSS selector against a subtree and returns matching node IDs. +/// +/// `scope` is the node to search within (typically the root element). +/// `selector` is a null-terminated UTF-8 CSS selector string. +/// +/// On success, sets `*out_count` to the number of matching nodes and returns +/// a heap-allocated array of `uint32_t` node IDs. The caller must free the +/// array with [`xmloxide_free_nodeid_array`]. +/// +/// Returns null on failure (parse error in selector or null arguments). +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. `selector` must be a valid +/// null-terminated UTF-8 string. `out_count` must be a valid pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_css_select( + doc: *const Document, + scope: u32, + selector: *const c_char, + out_count: *mut usize, +) -> *mut u32 { + clear_last_error(); + if doc.is_null() || selector.is_null() || out_count.is_null() { + set_last_error("null pointer argument"); + return std::ptr::null_mut(); + } + + // SAFETY: Null checks above. + let doc_ref = unsafe { &*doc }; + let css_sel = unsafe { CStr::from_ptr(selector) }; + let Ok(sel) = css_sel.to_str() else { + set_last_error("invalid UTF-8 in selector"); + return std::ptr::null_mut(); + }; + + let Some(scope_id) = crate::tree::NodeId::from_raw(scope) else { + set_last_error("invalid scope node id"); + return std::ptr::null_mut(); + }; + + match css::select(doc_ref, scope_id, sel) { + Ok(nodes) => { + let ids: Vec<u32> = nodes.iter().map(|n| n.into_raw()).collect(); + let len = ids.len(); + // SAFETY: out_count was checked non-null. + unsafe { *out_count = len }; + if ids.is_empty() { + // Return a non-null sentinel for empty results that can safely + // be freed (dangling pointer with zero length). + return std::ptr::NonNull::dangling().as_ptr(); + } + let boxed = ids.into_boxed_slice(); + Box::into_raw(boxed).cast::<u32>() + } + Err(e) => { + set_last_error(&e.to_string()); + std::ptr::null_mut() + } + } +} + +/// Frees a node ID array returned by [`xmloxide_css_select`]. +/// +/// Passing null is safe and does nothing. +/// +/// # Safety +/// +/// `ptr` must have been returned by `xmloxide_css_select` with the +/// corresponding `count`, or be null. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_free_nodeid_array(ptr: *mut u32, count: usize) { + if !ptr.is_null() && count > 0 { + // SAFETY: Pointer and count were returned by `xmloxide_css_select`. + unsafe { + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, count)); + } + } +} + +/// Returns the first node matching a CSS selector, or 0 if none found. +/// +/// This is a convenience wrapper — it evaluates the selector and returns +/// only the first match. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. `selector` must be a valid +/// null-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_css_select_first( + doc: *const Document, + scope: u32, + selector: *const c_char, +) -> u32 { + clear_last_error(); + if doc.is_null() || selector.is_null() { + set_last_error("null pointer argument"); + return 0; + } + + // SAFETY: Null checks above. + let doc_ref = unsafe { &*doc }; + let css_sel = unsafe { CStr::from_ptr(selector) }; + let Ok(sel) = css_sel.to_str() else { + set_last_error("invalid UTF-8 in selector"); + return 0; + }; + + let Some(scope_id) = crate::tree::NodeId::from_raw(scope) else { + set_last_error("invalid scope node id"); + return 0; + }; + + match css::select(doc_ref, scope_id, sel) { + Ok(nodes) => nodes.first().map_or(0, |n| n.into_raw()), + Err(e) => { + set_last_error(&e.to_string()); + 0 + } + } +} diff --git a/browser/vendor/xmloxide/src/ffi/document.rs b/browser/vendor/xmloxide/src/ffi/document.rs new file mode 100644 index 000000000..c7413a64b --- /dev/null +++ b/browser/vendor/xmloxide/src/ffi/document.rs @@ -0,0 +1,328 @@ +//! Document parsing and lifecycle FFI functions. +#![allow(unsafe_code, clippy::missing_safety_doc)] + +use std::ffi::CStr; +use std::os::raw::c_char; + +use crate::tree::Document; + +use crate::error::ErrorSeverity; + +use super::strings::to_c_string; +use super::{ + clear_last_error, set_last_error, set_last_error_structured, XMLOXIDE_ERR_ERROR, + XMLOXIDE_ERR_FATAL, XMLOXIDE_ERR_WARNING, +}; + +/// Parses a null-terminated UTF-8 XML string into a document. +/// +/// Returns a pointer to the document on success, or null on failure. +/// On failure, call [`xmloxide_last_error`](super::xmloxide_last_error) for details. +/// +/// The returned document must be freed with [`xmloxide_free_doc`]. +/// +/// # Safety +/// +/// `input` must be a valid null-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_parse_str(input: *const c_char) -> *mut Document { + clear_last_error(); + if input.is_null() { + set_last_error("null input pointer"); + return std::ptr::null_mut(); + } + // SAFETY: Null check above. Caller guarantees `input` is a valid null-terminated string. + let c_str = unsafe { CStr::from_ptr(input) }; + let s = match c_str.to_str() { + Ok(s) => s, + Err(e) => { + set_last_error(&format!("invalid UTF-8: {e}")); + return std::ptr::null_mut(); + } + }; + match Document::parse_str(s) { + Ok(doc) => Box::into_raw(Box::new(doc)), + Err(e) => { + set_last_error_structured( + &e.message, + e.location.line, + e.location.column, + XMLOXIDE_ERR_FATAL, + ); + std::ptr::null_mut() + } + } +} + +/// Parses raw bytes as XML, with automatic encoding detection. +/// +/// Returns a pointer to the document on success, or null on failure. +/// On failure, call [`xmloxide_last_error`](super::xmloxide_last_error) for details. +/// +/// The returned document must be freed with [`xmloxide_free_doc`]. +/// +/// # Safety +/// +/// `data` must point to `len` valid bytes. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_parse_bytes(data: *const u8, len: usize) -> *mut Document { + clear_last_error(); + if data.is_null() { + set_last_error("null data pointer"); + return std::ptr::null_mut(); + } + // SAFETY: Null check above. Caller guarantees `data` points to `len` valid bytes. + let bytes = unsafe { std::slice::from_raw_parts(data, len) }; + match Document::parse_bytes(bytes) { + Ok(doc) => Box::into_raw(Box::new(doc)), + Err(e) => { + set_last_error_structured( + &e.message, + e.location.line, + e.location.column, + XMLOXIDE_ERR_FATAL, + ); + std::ptr::null_mut() + } + } +} + +/// Frees a document previously returned by a parse function. +/// +/// Passing null is safe and does nothing. +/// +/// # Safety +/// +/// `doc` must have been returned by `xmloxide_parse_str` or +/// `xmloxide_parse_bytes`, or be null. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_free_doc(doc: *mut Document) { + if !doc.is_null() { + // SAFETY: `doc` was created by `Box::into_raw` in a parse function, and is non-null. + unsafe { + drop(Box::from_raw(doc)); + } + } +} + +/// Returns the XML version string from the document's XML declaration. +/// +/// Returns null if no version was declared. The returned string must +/// be freed with [`xmloxide_free_string`](super::strings::xmloxide_free_string). +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_doc_version(doc: *const Document) -> *mut c_char { + if doc.is_null() { + return std::ptr::null_mut(); + } + // SAFETY: Null check above. Caller guarantees `doc` is a valid pointer from a parse function. + let doc = unsafe { &*doc }; + match &doc.version { + Some(v) => to_c_string(v), + None => std::ptr::null_mut(), + } +} + +/// Returns the encoding string from the document's XML declaration. +/// +/// Returns null if no encoding was declared. The returned string must +/// be freed with [`xmloxide_free_string`](super::strings::xmloxide_free_string). +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_doc_encoding(doc: *const Document) -> *mut c_char { + if doc.is_null() { + return std::ptr::null_mut(); + } + // SAFETY: Null check above. Caller guarantees `doc` is a valid pointer from a parse function. + let doc = unsafe { &*doc }; + match &doc.encoding { + Some(e) => to_c_string(e), + None => std::ptr::null_mut(), + } +} + +/// Parses an HTML string into a document. +/// +/// Returns a pointer to the document on success, or null on failure. +/// The returned document must be freed with [`xmloxide_free_doc`]. +/// +/// # Safety +/// +/// `input` must be a valid null-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_parse_html(input: *const c_char) -> *mut Document { + clear_last_error(); + if input.is_null() { + set_last_error("null input pointer"); + return std::ptr::null_mut(); + } + // SAFETY: Null check above. Caller guarantees valid null-terminated string. + let c_str = unsafe { CStr::from_ptr(input) }; + let s = match c_str.to_str() { + Ok(s) => s, + Err(e) => { + set_last_error(&format!("invalid UTF-8: {e}")); + return std::ptr::null_mut(); + } + }; + match crate::html::parse_html(s) { + Ok(doc) => Box::into_raw(Box::new(doc)), + Err(e) => { + set_last_error_structured( + &e.message, + e.location.line, + e.location.column, + XMLOXIDE_ERR_FATAL, + ); + std::ptr::null_mut() + } + } +} + +/// Parses an XML file from a filesystem path. +/// +/// Returns a pointer to the document on success, or null on failure. +/// The returned document must be freed with [`xmloxide_free_doc`]. +/// +/// # Safety +/// +/// `path` must be a valid null-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_parse_file(path: *const c_char) -> *mut Document { + clear_last_error(); + if path.is_null() { + set_last_error("null path pointer"); + return std::ptr::null_mut(); + } + // SAFETY: Null check above. Caller guarantees valid null-terminated string. + let c_str = unsafe { CStr::from_ptr(path) }; + let s = match c_str.to_str() { + Ok(s) => s, + Err(e) => { + set_last_error(&format!("invalid UTF-8 in path: {e}")); + return std::ptr::null_mut(); + } + }; + match Document::parse_file(s) { + Ok(doc) => Box::into_raw(Box::new(doc)), + Err(e) => { + set_last_error_structured( + &e.message, + e.location.line, + e.location.column, + XMLOXIDE_ERR_FATAL, + ); + std::ptr::null_mut() + } + } +} + +/// Helper to convert `ErrorSeverity` to FFI severity constant. +fn severity_to_ffi(s: ErrorSeverity) -> i32 { + match s { + ErrorSeverity::Warning => XMLOXIDE_ERR_WARNING, + ErrorSeverity::Error => XMLOXIDE_ERR_ERROR, + ErrorSeverity::Fatal => XMLOXIDE_ERR_FATAL, + } +} + +/// Returns the number of parse diagnostics (warnings + recovered errors) +/// stored on a document. +/// +/// Documents parsed in recovery mode collect diagnostics during parsing. +/// Returns 0 if the document has no diagnostics or the pointer is null. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_doc_diagnostic_count(doc: *const Document) -> usize { + if doc.is_null() { + return 0; + } + let doc = unsafe { &*doc }; + doc.diagnostics.len() +} + +/// Returns the error message of the diagnostic at the given index. +/// +/// Returns null if the index is out of range. The returned string must +/// be freed with [`xmloxide_free_string`](super::strings::xmloxide_free_string). +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_doc_diagnostic_message( + doc: *const Document, + index: usize, +) -> *mut c_char { + if doc.is_null() { + return std::ptr::null_mut(); + } + let doc = unsafe { &*doc }; + match doc.diagnostics.get(index) { + Some(d) => to_c_string(&d.message), + None => std::ptr::null_mut(), + } +} + +/// Returns the line number of the diagnostic at the given index. +/// +/// Returns 0 if the index is out of range or the document pointer is null. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_doc_diagnostic_line(doc: *const Document, index: usize) -> u32 { + if doc.is_null() { + return 0; + } + let doc = unsafe { &*doc }; + doc.diagnostics.get(index).map_or(0, |d| d.location.line) +} + +/// Returns the column number of the diagnostic at the given index. +/// +/// Returns 0 if the index is out of range or the document pointer is null. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_doc_diagnostic_column(doc: *const Document, index: usize) -> u32 { + if doc.is_null() { + return 0; + } + let doc = unsafe { &*doc }; + doc.diagnostics.get(index).map_or(0, |d| d.location.column) +} + +/// Returns the severity of the diagnostic at the given index. +/// +/// Returns `XMLOXIDE_ERR_WARNING` (0), `XMLOXIDE_ERR_ERROR` (1), or +/// `XMLOXIDE_ERR_FATAL` (2). Returns -1 if out of range. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_doc_diagnostic_severity( + doc: *const Document, + index: usize, +) -> i32 { + if doc.is_null() { + return -1; + } + let doc = unsafe { &*doc }; + doc.diagnostics + .get(index) + .map_or(-1, |d| severity_to_ffi(d.severity)) +} diff --git a/browser/vendor/xmloxide/src/ffi/html5.rs b/browser/vendor/xmloxide/src/ffi/html5.rs new file mode 100644 index 000000000..805d9c96a --- /dev/null +++ b/browser/vendor/xmloxide/src/ffi/html5.rs @@ -0,0 +1,99 @@ +//! HTML5 parsing FFI functions. +#![allow(unsafe_code, clippy::missing_safety_doc)] + +use std::ffi::CStr; +use std::os::raw::c_char; + +use crate::html5::{parse_html5, parse_html5_with_options, Html5ParseOptions}; +use crate::tree::Document; + +use super::{clear_last_error, set_last_error}; + +/// Parses an HTML5 string into a document using the WHATWG parsing algorithm. +/// +/// Returns a pointer to the document on success, or null on failure. +/// The returned document must be freed with [`xmloxide_free_doc`](super::document::xmloxide_free_doc). +/// +/// # Safety +/// +/// `input` must be a valid null-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_parse_html5(input: *const c_char) -> *mut Document { + clear_last_error(); + if input.is_null() { + set_last_error("null input pointer"); + return std::ptr::null_mut(); + } + // SAFETY: Null check above. Caller guarantees valid null-terminated string. + let c_str = unsafe { CStr::from_ptr(input) }; + let s = match c_str.to_str() { + Ok(s) => s, + Err(e) => { + set_last_error(&format!("invalid UTF-8: {e}")); + return std::ptr::null_mut(); + } + }; + match parse_html5(s) { + Ok(doc) => Box::into_raw(Box::new(doc)), + Err(e) => { + set_last_error(&e.to_string()); + std::ptr::null_mut() + } + } +} + +/// Parses an HTML5 fragment with the given context element. +/// +/// This implements the fragment parsing algorithm (the algorithm behind +/// `innerHTML`). The `context_element` is the tag name of the context +/// (e.g., `"body"`, `"div"`, `"table"`). +/// +/// Returns a pointer to the document on success, or null on failure. +/// The returned document must be freed with [`xmloxide_free_doc`](super::document::xmloxide_free_doc). +/// +/// # Safety +/// +/// `input` and `context_element` must be valid null-terminated UTF-8 strings. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_parse_html5_fragment( + input: *const c_char, + context_element: *const c_char, +) -> *mut Document { + clear_last_error(); + if input.is_null() { + set_last_error("null input pointer"); + return std::ptr::null_mut(); + } + if context_element.is_null() { + set_last_error("null context_element pointer"); + return std::ptr::null_mut(); + } + // SAFETY: Null checks above. Caller guarantees valid null-terminated strings. + let c_input = unsafe { CStr::from_ptr(input) }; + let s = match c_input.to_str() { + Ok(s) => s, + Err(e) => { + set_last_error(&format!("invalid UTF-8 in input: {e}")); + return std::ptr::null_mut(); + } + }; + let c_ctx = unsafe { CStr::from_ptr(context_element) }; + let ctx = match c_ctx.to_str() { + Ok(s) => s, + Err(e) => { + set_last_error(&format!("invalid UTF-8 in context_element: {e}")); + return std::ptr::null_mut(); + } + }; + let opts = Html5ParseOptions { + scripting: false, + fragment_context: Some(ctx.to_string()), + }; + match parse_html5_with_options(s, &opts) { + Ok(doc) => Box::into_raw(Box::new(doc)), + Err(e) => { + set_last_error(&e.to_string()); + std::ptr::null_mut() + } + } +} diff --git a/browser/vendor/xmloxide/src/ffi/mod.rs b/browser/vendor/xmloxide/src/ffi/mod.rs new file mode 100644 index 000000000..b78d3da54 --- /dev/null +++ b/browser/vendor/xmloxide/src/ffi/mod.rs @@ -0,0 +1,135 @@ +//! C FFI layer for xmloxide. +//! +//! Provides a C-compatible API for using xmloxide from C/C++ and other +//! languages that support C FFI. All symbols use the `xmloxide_` prefix. +//! +//! # Error Handling +//! +//! Functions that can fail return null pointers (for pointer types) or 0 +//! (for `NodeId` values). The last error message is stored in thread-local +//! storage and can be retrieved via [`xmloxide_last_error`]. +//! +//! # String Ownership +//! +//! All strings returned by FFI functions are caller-owned C strings that +//! must be freed via [`xmloxide_free_string`](strings::xmloxide_free_string). +//! +//! # Safety +//! +//! All `extern "C"` functions in this module are inherently unsafe because +//! they accept raw pointers from C callers. + +// FFI functions require unsafe blocks throughout. +#![allow(unsafe_code, clippy::missing_safety_doc)] + +pub mod c14n; +pub mod catalog; +pub mod css; +pub mod document; +pub mod html5; +pub mod push; +pub mod reader; +pub mod sax; +pub mod serial; +pub mod strings; +pub mod tree; +pub mod validation; +pub mod xinclude; +pub mod xpath; + +use std::cell::RefCell; +use std::ffi::CString; +use std::os::raw::c_char; + +/// Structured error stored in thread-local storage. +struct StructuredError { + message: CString, + line: u32, + column: u32, + severity: i32, // 0=warning, 1=error, 2=fatal +} + +/// Severity constants matching libxml2's `xmlErrorLevel`. +pub const XMLOXIDE_ERR_WARNING: i32 = 0; +pub const XMLOXIDE_ERR_ERROR: i32 = 1; +pub const XMLOXIDE_ERR_FATAL: i32 = 2; + +thread_local! { + static LAST_ERROR: RefCell<Option<StructuredError>> = const { RefCell::new(None) }; +} + +/// Stores an error message in thread-local storage (no location info). +fn set_last_error(msg: &str) { + LAST_ERROR.with(|cell| { + *cell.borrow_mut() = CString::new(msg).ok().map(|message| StructuredError { + message, + line: 0, + column: 0, + severity: XMLOXIDE_ERR_FATAL, + }); + }); +} + +/// Stores a structured error with location in thread-local storage. +fn set_last_error_structured(msg: &str, line: u32, column: u32, severity: i32) { + LAST_ERROR.with(|cell| { + *cell.borrow_mut() = CString::new(msg).ok().map(|message| StructuredError { + message, + line, + column, + severity, + }); + }); +} + +/// Clears the thread-local error. +fn clear_last_error() { + LAST_ERROR.with(|cell| { + *cell.borrow_mut() = None; + }); +} + +/// Returns the last error message, or null if no error occurred. +/// +/// The returned string is owned by the library and must NOT be freed +/// by the caller. It is valid until the next FFI call on the same thread. +#[no_mangle] +pub extern "C" fn xmloxide_last_error() -> *const c_char { + LAST_ERROR.with(|cell| { + let borrow = cell.borrow(); + match borrow.as_ref() { + Some(e) => e.message.as_ptr(), + None => std::ptr::null(), + } + }) +} + +/// Returns the line number of the last error, or 0 if unknown. +#[no_mangle] +pub extern "C" fn xmloxide_last_error_line() -> u32 { + LAST_ERROR.with(|cell| { + let borrow = cell.borrow(); + borrow.as_ref().map_or(0, |e| e.line) + }) +} + +/// Returns the column number of the last error, or 0 if unknown. +#[no_mangle] +pub extern "C" fn xmloxide_last_error_column() -> u32 { + LAST_ERROR.with(|cell| { + let borrow = cell.borrow(); + borrow.as_ref().map_or(0, |e| e.column) + }) +} + +/// Returns the severity of the last error. +/// +/// Returns `XMLOXIDE_ERR_WARNING` (0), `XMLOXIDE_ERR_ERROR` (1), or +/// `XMLOXIDE_ERR_FATAL` (2). Returns -1 if no error occurred. +#[no_mangle] +pub extern "C" fn xmloxide_last_error_severity() -> i32 { + LAST_ERROR.with(|cell| { + let borrow = cell.borrow(); + borrow.as_ref().map_or(-1, |e| e.severity) + }) +} diff --git a/browser/vendor/xmloxide/src/ffi/push.rs b/browser/vendor/xmloxide/src/ffi/push.rs new file mode 100644 index 000000000..ceeaa24e0 --- /dev/null +++ b/browser/vendor/xmloxide/src/ffi/push.rs @@ -0,0 +1,90 @@ +//! FFI wrappers for the push/incremental parser. +#![allow(unsafe_code, clippy::missing_safety_doc)] + +use crate::ffi::{clear_last_error, set_last_error}; +use crate::parser::PushParser; +use crate::tree::Document; + +/// Creates a new push parser with default options. +/// +/// Returns a pointer to the parser, or null on failure. +/// The parser must be freed with [`xmloxide_push_parser_free`] or consumed +/// by [`xmloxide_push_parser_finish`]. +#[no_mangle] +pub extern "C" fn xmloxide_push_parser_new() -> *mut PushParser { + clear_last_error(); + Box::into_raw(Box::new(PushParser::new())) +} + +/// Feeds a chunk of raw bytes into the push parser. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_push_parser_push( + parser: *mut PushParser, + data: *const u8, + len: usize, +) { + if parser.is_null() || data.is_null() { + return; + } + let parser = &mut *parser; + let slice = std::slice::from_raw_parts(data, len); + parser.push(slice); +} + +/// Finalizes parsing and returns the constructed document. +/// +/// This **consumes** the parser — the parser pointer becomes invalid after +/// this call and must not be used again. Do NOT call `xmloxide_push_parser_free` +/// on a parser that has been finished. +/// +/// Returns a document pointer on success, or null on failure. +/// The returned document must be freed with `xmloxide_free_doc`. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_push_parser_finish(parser: *mut PushParser) -> *mut Document { + if parser.is_null() { + set_last_error("null parser pointer"); + return std::ptr::null_mut(); + } + clear_last_error(); + let parser = *Box::from_raw(parser); + match parser.finish() { + Ok(doc) => Box::into_raw(Box::new(doc)), + Err(e) => { + set_last_error(&e.to_string()); + std::ptr::null_mut() + } + } +} + +/// Returns the number of bytes currently buffered in the push parser. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_push_parser_buffered_bytes(parser: *const PushParser) -> usize { + if parser.is_null() { + return 0; + } + (*parser).buffered_bytes() +} + +/// Resets the push parser, discarding all buffered data. +/// +/// After this call the parser is in the same state as a newly created one +/// and can be reused for another document. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_push_parser_reset(parser: *mut PushParser) { + if parser.is_null() { + return; + } + (*parser).reset(); +} + +/// Frees a push parser without finishing it. +/// +/// Use this to discard a parser whose data you no longer need. +/// Passing null is safe and does nothing. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_push_parser_free(parser: *mut PushParser) { + if parser.is_null() { + return; + } + drop(Box::from_raw(parser)); +} diff --git a/browser/vendor/xmloxide/src/ffi/reader.rs b/browser/vendor/xmloxide/src/ffi/reader.rs new file mode 100644 index 000000000..1dd15c045 --- /dev/null +++ b/browser/vendor/xmloxide/src/ffi/reader.rs @@ -0,0 +1,317 @@ +//! FFI wrappers for the `XmlReader` pull-based streaming API. +#![allow(unsafe_code, clippy::missing_safety_doc)] + +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; + +use crate::ffi::{clear_last_error, set_last_error}; +use crate::reader::{XmlNodeType, XmlReader}; + +/// FFI-safe reader that owns the input string. +/// +/// The Rust `XmlReader<'a>` borrows its input, but C callers need an +/// opaque handle that owns everything. We heap-allocate the input +/// string and create a reader that borrows from it with an erased +/// lifetime. This is safe because the string is never moved or +/// reallocated while the reader exists. +/// Opaque reader handle for FFI consumers. +/// +/// This struct has no public fields — C callers interact with it +/// exclusively through the `xmloxide_reader_*` functions. +pub struct FfiReader { + /// The owned input string, heap-allocated and never moved. + /// Must be declared before `reader` so it outlives it during drop. + _input: Box<str>, + /// The reader. Its lifetime is tied to `_input` but erased to `'static`. + reader: XmlReader<'static>, +} + +impl FfiReader { + fn new(input: String) -> Self { + let boxed: Box<str> = input.into_boxed_str(); + // SAFETY: We extend the borrow's lifetime to 'static. This is safe + // because `_input` is heap-allocated, never moved or reallocated, + // and outlives `reader` (fields are dropped in declaration order, + // so `reader` is dropped before `_input`). + let reader = unsafe { + let static_ref: &'static str = &*(std::ptr::from_ref::<str>(&boxed)); + XmlReader::new(static_ref) + }; + Self { + _input: boxed, + reader, + } + } +} + +// --- XmlReader node type constants matching libxml2's xmlReaderTypes --- + +/// No node (reader not yet advanced). +pub const XMLOXIDE_READER_NONE: i32 = 0; +/// Element start tag. +pub const XMLOXIDE_READER_ELEMENT: i32 = 1; +/// Attribute. +pub const XMLOXIDE_READER_ATTRIBUTE: i32 = 2; +/// Text node. +pub const XMLOXIDE_READER_TEXT: i32 = 3; +/// CDATA section. +pub const XMLOXIDE_READER_CDATA: i32 = 4; +/// Processing instruction. +pub const XMLOXIDE_READER_PI: i32 = 7; +/// XML comment. +pub const XMLOXIDE_READER_COMMENT: i32 = 8; +/// Document type declaration. +pub const XMLOXIDE_READER_DOCUMENT_TYPE: i32 = 10; +/// Whitespace-only text. +pub const XMLOXIDE_READER_WHITESPACE: i32 = 13; +/// Element end tag. +pub const XMLOXIDE_READER_END_ELEMENT: i32 = 15; +/// XML declaration. +pub const XMLOXIDE_READER_XML_DECLARATION: i32 = 17; +/// End of document. +pub const XMLOXIDE_READER_END_DOCUMENT: i32 = -1; + +fn node_type_to_int(nt: XmlNodeType) -> i32 { + match nt { + XmlNodeType::None => XMLOXIDE_READER_NONE, + XmlNodeType::Element => XMLOXIDE_READER_ELEMENT, + XmlNodeType::EndElement => XMLOXIDE_READER_END_ELEMENT, + XmlNodeType::Text => XMLOXIDE_READER_TEXT, + XmlNodeType::CData => XMLOXIDE_READER_CDATA, + XmlNodeType::Comment => XMLOXIDE_READER_COMMENT, + XmlNodeType::ProcessingInstruction => XMLOXIDE_READER_PI, + XmlNodeType::XmlDeclaration => XMLOXIDE_READER_XML_DECLARATION, + XmlNodeType::DocumentType => XMLOXIDE_READER_DOCUMENT_TYPE, + XmlNodeType::Whitespace => XMLOXIDE_READER_WHITESPACE, + XmlNodeType::Attribute => XMLOXIDE_READER_ATTRIBUTE, + XmlNodeType::EndDocument => XMLOXIDE_READER_END_DOCUMENT, + } +} + +fn to_c_string(s: &str) -> *mut c_char { + match CString::new(s) { + Ok(cs) => cs.into_raw(), + Err(_) => std::ptr::null_mut(), + } +} + +/// Creates a new `XmlReader` from a null-terminated UTF-8 string. +/// +/// Returns an opaque reader pointer, or null on failure. +/// The reader must be freed with [`xmloxide_reader_free`]. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_reader_new(input: *const c_char) -> *mut FfiReader { + if input.is_null() { + set_last_error("null input pointer"); + return std::ptr::null_mut(); + } + clear_last_error(); + let c_str = CStr::from_ptr(input); + let Ok(s) = c_str.to_str() else { + set_last_error("input is not valid UTF-8"); + return std::ptr::null_mut(); + }; + Box::into_raw(Box::new(FfiReader::new(s.to_string()))) +} + +/// Advances the reader to the next node. +/// +/// Returns 1 if the reader advanced to a node, 0 if the document ended, +/// or -1 on error. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_reader_read(reader: *mut FfiReader) -> i32 { + if reader.is_null() { + return -1; + } + match (*reader).reader.read() { + Ok(true) => 1, + Ok(false) => 0, + Err(e) => { + set_last_error(&e.to_string()); + -1 + } + } +} + +/// Returns the node type of the current node. +/// +/// Returns one of the `XMLOXIDE_READER_*` constants. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_reader_node_type(reader: *const FfiReader) -> i32 { + if reader.is_null() { + return XMLOXIDE_READER_NONE; + } + node_type_to_int((*reader).reader.node_type()) +} + +/// Returns the name of the current node, or null. +/// +/// The returned string must be freed with `xmloxide_free_string`. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_reader_name(reader: *const FfiReader) -> *mut c_char { + if reader.is_null() { + return std::ptr::null_mut(); + } + match (*reader).reader.name() { + Some(name) => to_c_string(name), + None => std::ptr::null_mut(), + } +} + +/// Returns the local name of the current node (without prefix), or null. +/// +/// The returned string must be freed with `xmloxide_free_string`. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_reader_local_name(reader: *const FfiReader) -> *mut c_char { + if reader.is_null() { + return std::ptr::null_mut(); + } + match (*reader).reader.local_name() { + Some(name) => to_c_string(name), + None => std::ptr::null_mut(), + } +} + +/// Returns the namespace prefix of the current node, or null. +/// +/// The returned string must be freed with `xmloxide_free_string`. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_reader_prefix(reader: *const FfiReader) -> *mut c_char { + if reader.is_null() { + return std::ptr::null_mut(); + } + match (*reader).reader.prefix() { + Some(p) => to_c_string(p), + None => std::ptr::null_mut(), + } +} + +/// Returns the namespace URI of the current node, or null. +/// +/// The returned string must be freed with `xmloxide_free_string`. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_reader_namespace_uri(reader: *const FfiReader) -> *mut c_char { + if reader.is_null() { + return std::ptr::null_mut(); + } + match (*reader).reader.namespace_uri() { + Some(ns) => to_c_string(ns), + None => std::ptr::null_mut(), + } +} + +/// Returns the value of the current node (text content, comment, etc.), or null. +/// +/// The returned string must be freed with `xmloxide_free_string`. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_reader_value(reader: *const FfiReader) -> *mut c_char { + if reader.is_null() { + return std::ptr::null_mut(); + } + match (*reader).reader.value() { + Some(v) => to_c_string(v), + None => std::ptr::null_mut(), + } +} + +/// Returns the depth of the current node in the document tree. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_reader_depth(reader: *const FfiReader) -> u32 { + if reader.is_null() { + return 0; + } + (*reader).reader.depth() +} + +/// Returns whether the current element is a self-closing (empty) element. +/// +/// Returns 1 for empty elements, 0 otherwise. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_reader_is_empty_element(reader: *const FfiReader) -> i32 { + if reader.is_null() { + return 0; + } + i32::from((*reader).reader.is_empty_element()) +} + +/// Returns whether the current node has a value. +/// +/// Returns 1 if it has a value, 0 otherwise. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_reader_has_value(reader: *const FfiReader) -> i32 { + if reader.is_null() { + return 0; + } + i32::from((*reader).reader.has_value()) +} + +/// Returns the number of attributes on the current element. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_reader_attribute_count(reader: *const FfiReader) -> usize { + if reader.is_null() { + return 0; + } + (*reader).reader.attribute_count() +} + +/// Returns the value of an attribute by name on the current element, or null. +/// +/// The returned string must be freed with `xmloxide_free_string`. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_reader_get_attribute( + reader: *const FfiReader, + name: *const c_char, +) -> *mut c_char { + if reader.is_null() || name.is_null() { + return std::ptr::null_mut(); + } + let Ok(name) = CStr::from_ptr(name).to_str() else { + return std::ptr::null_mut(); + }; + match (*reader).reader.get_attribute(name) { + Some(v) => to_c_string(v), + None => std::ptr::null_mut(), + } +} + +/// Moves the reader to the first attribute of the current element. +/// +/// Returns 1 if successful, 0 if no attributes or not on an element. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_reader_move_to_first_attribute(reader: *mut FfiReader) -> i32 { + if reader.is_null() { + return 0; + } + i32::from((*reader).reader.move_to_first_attribute()) +} + +/// Moves the reader to the next attribute of the current element. +/// +/// Returns 1 if successful, 0 if no more attributes. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_reader_move_to_next_attribute(reader: *mut FfiReader) -> i32 { + if reader.is_null() { + return 0; + } + i32::from((*reader).reader.move_to_next_attribute()) +} + +/// Moves the reader back to the element from an attribute. +/// +/// Returns 1 if the reader was moved back, 0 if not on an attribute. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_reader_move_to_element(reader: *mut FfiReader) -> i32 { + if reader.is_null() { + return 0; + } + i32::from((*reader).reader.move_to_element()) +} + +/// Frees a reader. Passing null is safe and does nothing. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_reader_free(reader: *mut FfiReader) { + if reader.is_null() { + return; + } + drop(Box::from_raw(reader)); +} diff --git a/browser/vendor/xmloxide/src/ffi/sax.rs b/browser/vendor/xmloxide/src/ffi/sax.rs new file mode 100644 index 000000000..cb0eaae42 --- /dev/null +++ b/browser/vendor/xmloxide/src/ffi/sax.rs @@ -0,0 +1,193 @@ +//! FFI wrappers for the SAX2 streaming parser. +#![allow(unsafe_code, clippy::missing_safety_doc)] + +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; + +use crate::ffi::{clear_last_error, set_last_error}; +use crate::parser::ParseOptions; +use crate::sax::{self, SaxHandler}; + +/// C function pointer type for `start_element` events. +/// +/// Arguments: `local_name`, `prefix` (may be null), `namespace` (may be null), +/// `attr_names` array, `attr_values` array, `attr_count`, `user_data`. +pub type StartElementCb = Option< + unsafe extern "C" fn( + *const c_char, + *const c_char, + *const c_char, + *const *const c_char, + *const *const c_char, + usize, + *mut std::ffi::c_void, + ), +>; + +/// C function pointer type for `end_element` events. +/// +/// Arguments: `local_name`, `prefix` (may be null), `namespace` (may be null), +/// `user_data`. +pub type EndElementCb = Option< + unsafe extern "C" fn(*const c_char, *const c_char, *const c_char, *mut std::ffi::c_void), +>; + +/// C function pointer type for `characters` / `cdata` / `comment` events. +/// +/// Arguments: `content`, `user_data`. +pub type TextCb = Option<unsafe extern "C" fn(*const c_char, *mut std::ffi::c_void)>; + +/// C function pointer type for `processing_instruction` events. +/// +/// Arguments: `target`, `data` (may be null), `user_data`. +pub type PiCb = Option<unsafe extern "C" fn(*const c_char, *const c_char, *mut std::ffi::c_void)>; + +/// A SAX handler specified as C function pointers. +/// +/// Set any callback to `NULL` to ignore that event type. +/// `user_data` is passed through to every callback. +#[repr(C)] +pub struct XmloxideSaxHandler { + pub start_element: StartElementCb, + pub end_element: EndElementCb, + pub characters: TextCb, + pub cdata: TextCb, + pub comment: TextCb, + pub processing_instruction: PiCb, + pub user_data: *mut std::ffi::c_void, +} + +/// Bridge that implements the Rust `SaxHandler` trait by forwarding events +/// to C function pointers. +struct FfiSaxBridge { + handler: *const XmloxideSaxHandler, +} + +impl SaxHandler for FfiSaxBridge { + fn start_element( + &mut self, + local_name: &str, + prefix: Option<&str>, + namespace: Option<&str>, + attributes: &[(String, String, Option<String>, Option<String>)], + ) { + // SAFETY: handler pointer validity is the caller's responsibility. + let h = unsafe { &*self.handler }; + let Some(cb) = h.start_element else { return }; + + let c_local = CString::new(local_name).unwrap_or_default(); + let c_prefix = prefix.and_then(|s| CString::new(s).ok()); + let c_ns = namespace.and_then(|s| CString::new(s).ok()); + + // Build parallel arrays of attribute names and values. + let c_names: Vec<CString> = attributes + .iter() + .filter_map(|(name, _, _, _)| CString::new(name.as_str()).ok()) + .collect(); + let c_values: Vec<CString> = attributes + .iter() + .filter_map(|(_, value, _, _)| CString::new(value.as_str()).ok()) + .collect(); + let name_ptrs: Vec<*const c_char> = c_names.iter().map(|s| s.as_ptr()).collect(); + let value_ptrs: Vec<*const c_char> = c_values.iter().map(|s| s.as_ptr()).collect(); + + unsafe { + cb( + c_local.as_ptr(), + c_prefix.as_ref().map_or(std::ptr::null(), |s| s.as_ptr()), + c_ns.as_ref().map_or(std::ptr::null(), |s| s.as_ptr()), + name_ptrs.as_ptr(), + value_ptrs.as_ptr(), + c_names.len(), + h.user_data, + ); + } + } + + fn end_element(&mut self, local_name: &str, prefix: Option<&str>, namespace: Option<&str>) { + let h = unsafe { &*self.handler }; + let Some(cb) = h.end_element else { return }; + + let c_local = CString::new(local_name).unwrap_or_default(); + let c_prefix = prefix.and_then(|s| CString::new(s).ok()); + let c_ns = namespace.and_then(|s| CString::new(s).ok()); + + unsafe { + cb( + c_local.as_ptr(), + c_prefix.as_ref().map_or(std::ptr::null(), |s| s.as_ptr()), + c_ns.as_ref().map_or(std::ptr::null(), |s| s.as_ptr()), + h.user_data, + ); + } + } + + fn characters(&mut self, content: &str) { + let h = unsafe { &*self.handler }; + let Some(cb) = h.characters else { return }; + let c_content = CString::new(content).unwrap_or_default(); + unsafe { cb(c_content.as_ptr(), h.user_data) }; + } + + fn cdata(&mut self, content: &str) { + let h = unsafe { &*self.handler }; + let Some(cb) = h.cdata else { return }; + let c_content = CString::new(content).unwrap_or_default(); + unsafe { cb(c_content.as_ptr(), h.user_data) }; + } + + fn comment(&mut self, content: &str) { + let h = unsafe { &*self.handler }; + let Some(cb) = h.comment else { return }; + let c_content = CString::new(content).unwrap_or_default(); + unsafe { cb(c_content.as_ptr(), h.user_data) }; + } + + fn processing_instruction(&mut self, target: &str, data: Option<&str>) { + let h = unsafe { &*self.handler }; + let Some(cb) = h.processing_instruction else { + return; + }; + let c_target = CString::new(target).unwrap_or_default(); + let c_data = data.and_then(|s| CString::new(s).ok()); + unsafe { + cb( + c_target.as_ptr(), + c_data.as_ref().map_or(std::ptr::null(), |s| s.as_ptr()), + h.user_data, + ); + } + } +} + +/// Parses XML with SAX streaming, dispatching events to C function pointers. +/// +/// `xml` must be a valid null-terminated UTF-8 C string. +/// `handler` must point to a valid `XmloxideSaxHandler` struct. +/// +/// Returns 0 on success, -1 on error. Use `xmloxide_last_error()` for details. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_sax_parse( + xml: *const c_char, + handler: *const XmloxideSaxHandler, +) -> i32 { + if xml.is_null() || handler.is_null() { + set_last_error("null argument"); + return -1; + } + clear_last_error(); + + let Ok(input) = CStr::from_ptr(xml).to_str() else { + set_last_error("invalid UTF-8 in input"); + return -1; + }; + + let mut bridge = FfiSaxBridge { handler }; + match sax::parse_sax(input, &ParseOptions::default(), &mut bridge) { + Ok(()) => 0, + Err(e) => { + set_last_error(&e.to_string()); + -1 + } + } +} diff --git a/browser/vendor/xmloxide/src/ffi/serial.rs b/browser/vendor/xmloxide/src/ffi/serial.rs new file mode 100644 index 000000000..1642e56ce --- /dev/null +++ b/browser/vendor/xmloxide/src/ffi/serial.rs @@ -0,0 +1,136 @@ +//! Serialization FFI functions. +#![allow(unsafe_code, clippy::missing_safety_doc)] + +use std::ffi::CStr; +use std::os::raw::c_char; + +use crate::serial::SerializeOptions; +use crate::tree::Document; + +use super::strings::to_c_string; +use super::{clear_last_error, set_last_error}; + +/// Serializes a document to an XML string. +/// +/// Returns a caller-owned C string that must be freed with +/// `xmloxide_free_string`. Returns null on failure. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_serialize(doc: *const Document) -> *mut c_char { + clear_last_error(); + if doc.is_null() { + set_last_error("null document pointer"); + return std::ptr::null_mut(); + } + // SAFETY: Null check above. Caller guarantees `doc` is a valid pointer from a parse function. + let doc = unsafe { &*doc }; + let output = crate::serial::serialize(doc); + to_c_string(&output) +} + +/// Serializes a document to a pretty-printed XML string. +/// +/// Uses the default two-space indentation. Returns a caller-owned C string +/// that must be freed with `xmloxide_free_string`. Returns null on failure. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_serialize_pretty(doc: *const Document) -> *mut c_char { + clear_last_error(); + if doc.is_null() { + set_last_error("null document pointer"); + return std::ptr::null_mut(); + } + // SAFETY: Null check above. Caller guarantees `doc` is a valid pointer from a parse function. + let doc = unsafe { &*doc }; + let opts = SerializeOptions::default().indent(true); + let output = crate::serial::serialize_with_options(doc, &opts); + to_c_string(&output) +} + +/// Serializes a document to a pretty-printed XML string with a custom indent. +/// +/// `indent_str` is the string used for each indentation level (e.g., `"\t"` +/// or `" "`). Returns a caller-owned C string that must be freed with +/// `xmloxide_free_string`. Returns null on failure. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. `indent_str` must be a valid +/// null-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_serialize_pretty_custom( + doc: *const Document, + indent_str: *const c_char, +) -> *mut c_char { + clear_last_error(); + if doc.is_null() { + set_last_error("null document pointer"); + return std::ptr::null_mut(); + } + if indent_str.is_null() { + set_last_error("null indent_str pointer"); + return std::ptr::null_mut(); + } + // SAFETY: Null checks above. Caller guarantees valid pointers. + let doc = unsafe { &*doc }; + let c_indent = unsafe { CStr::from_ptr(indent_str) }; + let Ok(indent) = c_indent.to_str() else { + set_last_error("invalid UTF-8 in indent_str"); + return std::ptr::null_mut(); + }; + let opts = SerializeOptions::default().indent(true).indent_str(indent); + let output = crate::serial::serialize_with_options(doc, &opts); + to_c_string(&output) +} + +/// Serializes a document to an HTML string. +/// +/// Returns a caller-owned C string that must be freed with +/// `xmloxide_free_string`. Returns null on failure. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_serialize_html(doc: *const Document) -> *mut c_char { + clear_last_error(); + if doc.is_null() { + set_last_error("null document pointer"); + return std::ptr::null_mut(); + } + // SAFETY: Null check above. Caller guarantees `doc` is a valid pointer from a parse function. + let doc = unsafe { &*doc }; + let output = crate::serial::html::serialize_html(doc); + to_c_string(&output) +} + +/// Serializes a document to an HTML5 string. +/// +/// Uses the WHATWG HTML serialization algorithm: void elements are not +/// self-closed, raw text elements (`<script>`, `<style>`) are not escaped, +/// and foreign content (`SVG`/`MathML`) uses self-closing tags when empty. +/// +/// Returns a caller-owned C string that must be freed with +/// `xmloxide_free_string`. Returns null on failure. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_serialize_html5(doc: *const Document) -> *mut c_char { + clear_last_error(); + if doc.is_null() { + set_last_error("null document pointer"); + return std::ptr::null_mut(); + } + // SAFETY: Null check above. Caller guarantees `doc` is a valid pointer from a parse function. + let doc = unsafe { &*doc }; + let output = crate::serial::html::serialize_html5(doc); + to_c_string(&output) +} diff --git a/browser/vendor/xmloxide/src/ffi/strings.rs b/browser/vendor/xmloxide/src/ffi/strings.rs new file mode 100644 index 000000000..bd83c0b65 --- /dev/null +++ b/browser/vendor/xmloxide/src/ffi/strings.rs @@ -0,0 +1,33 @@ +//! String lifecycle helpers for the FFI layer. +#![allow(unsafe_code)] + +use std::ffi::CString; +use std::os::raw::c_char; + +/// Converts a Rust `&str` to a caller-owned C string. +/// +/// Returns null if the string contains interior null bytes. +pub(crate) fn to_c_string(s: &str) -> *mut c_char { + match CString::new(s) { + Ok(cs) => cs.into_raw(), + Err(_) => std::ptr::null_mut(), + } +} + +/// Frees a string previously returned by an xmloxide FFI function. +/// +/// Passing null is safe and does nothing. +/// +/// # Safety +/// +/// The pointer must have been returned by an xmloxide FFI function, +/// or be null. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_free_string(ptr: *mut c_char) { + if !ptr.is_null() { + // SAFETY: `ptr` was created by `CString::into_raw` via `to_c_string`, and is non-null. + unsafe { + drop(CString::from_raw(ptr)); + } + } +} diff --git a/browser/vendor/xmloxide/src/ffi/tree.rs b/browser/vendor/xmloxide/src/ffi/tree.rs new file mode 100644 index 000000000..ddba12044 --- /dev/null +++ b/browser/vendor/xmloxide/src/ffi/tree.rs @@ -0,0 +1,734 @@ +//! Tree navigation and node inspection FFI functions. +#![allow(unsafe_code, clippy::missing_safety_doc)] + +use std::os::raw::c_char; + +use crate::tree::{Document, NodeId, NodeKind}; + +use super::strings::to_c_string; + +// Node type constants matching common XML conventions. + +/// Element node type constant. +pub const XMLOXIDE_NODE_ELEMENT: i32 = 1; +/// Text node type constant. +pub const XMLOXIDE_NODE_TEXT: i32 = 3; +/// CDATA section node type constant. +pub const XMLOXIDE_NODE_CDATA: i32 = 4; +/// Entity reference node type constant. +pub const XMLOXIDE_NODE_ENTITY_REF: i32 = 5; +/// Processing instruction node type constant. +pub const XMLOXIDE_NODE_PI: i32 = 7; +/// Comment node type constant. +pub const XMLOXIDE_NODE_COMMENT: i32 = 8; +/// Document node type constant. +pub const XMLOXIDE_NODE_DOCUMENT: i32 = 9; +/// Document type node type constant. +pub const XMLOXIDE_NODE_DOCUMENT_TYPE: i32 = 10; + +/// Helper to convert `Option<NodeId>` to a raw u32 (0 = no node). +fn node_id_to_raw(id: Option<NodeId>) -> u32 { + id.map_or(0, NodeId::into_raw) +} + +/// Helper to safely dereference a document pointer and node id. +/// +/// Returns `None` if either the document is null or the raw node id is 0. +unsafe fn doc_and_node(doc: *const Document, raw_node: u32) -> Option<(&'static Document, NodeId)> { + if doc.is_null() { + return None; + } + // SAFETY: Null check above. Caller guarantees `doc` is a valid pointer from a parse function. + let doc = unsafe { &*doc }; + let node_id = NodeId::from_raw(raw_node)?; + Some((doc, node_id)) +} + +/// Returns the document root node id. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_doc_root(doc: *const Document) -> u32 { + if doc.is_null() { + return 0; + } + // SAFETY: Null check above. Caller guarantees `doc` is a valid pointer from a parse function. + let doc = unsafe { &*doc }; + doc.root().into_raw() +} + +/// Returns the root element of the document, or 0 if none. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_doc_root_element(doc: *const Document) -> u32 { + if doc.is_null() { + return 0; + } + // SAFETY: Null check above. Caller guarantees `doc` is a valid pointer from a parse function. + let doc = unsafe { &*doc }; + node_id_to_raw(doc.root_element()) +} + +/// Returns the parent of a node, or 0 if none. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_node_parent(doc: *const Document, node: u32) -> u32 { + let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { + return 0; + }; + node_id_to_raw(doc.parent(node_id)) +} + +/// Returns the first child of a node, or 0 if none. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_node_first_child(doc: *const Document, node: u32) -> u32 { + let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { + return 0; + }; + node_id_to_raw(doc.first_child(node_id)) +} + +/// Returns the last child of a node, or 0 if none. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_node_last_child(doc: *const Document, node: u32) -> u32 { + let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { + return 0; + }; + node_id_to_raw(doc.last_child(node_id)) +} + +/// Returns the next sibling of a node, or 0 if none. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_node_next_sibling(doc: *const Document, node: u32) -> u32 { + let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { + return 0; + }; + node_id_to_raw(doc.next_sibling(node_id)) +} + +/// Returns the previous sibling of a node, or 0 if none. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_node_prev_sibling(doc: *const Document, node: u32) -> u32 { + let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { + return 0; + }; + node_id_to_raw(doc.prev_sibling(node_id)) +} + +/// Returns the node type as an integer constant. +/// +/// Returns -1 if the document or node is invalid. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_node_type(doc: *const Document, node: u32) -> i32 { + let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { + return -1; + }; + match &doc.node(node_id).kind { + NodeKind::Document => XMLOXIDE_NODE_DOCUMENT, + NodeKind::Element { .. } => XMLOXIDE_NODE_ELEMENT, + NodeKind::Text { .. } => XMLOXIDE_NODE_TEXT, + NodeKind::CData { .. } => XMLOXIDE_NODE_CDATA, + NodeKind::Comment { .. } => XMLOXIDE_NODE_COMMENT, + NodeKind::ProcessingInstruction { .. } => XMLOXIDE_NODE_PI, + NodeKind::EntityRef { .. } => XMLOXIDE_NODE_ENTITY_REF, + NodeKind::DocumentType { .. } => XMLOXIDE_NODE_DOCUMENT_TYPE, + } +} + +/// Returns the name of a node (element local name or PI target). +/// +/// Returns null for node types that have no name. The returned string +/// must be freed with `xmloxide_free_string`. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_node_name(doc: *const Document, node: u32) -> *mut c_char { + let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { + return std::ptr::null_mut(); + }; + match doc.node_name(node_id) { + Some(name) => to_c_string(name), + None => std::ptr::null_mut(), + } +} + +/// Returns the direct text content of a text, comment, CDATA, or PI node. +/// +/// Returns null for element and document nodes. The returned string +/// must be freed with `xmloxide_free_string`. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_node_text(doc: *const Document, node: u32) -> *mut c_char { + let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { + return std::ptr::null_mut(); + }; + match doc.node_text(node_id) { + Some(text) => to_c_string(text), + None => std::ptr::null_mut(), + } +} + +/// Returns the concatenated text content of a node and all descendants. +/// +/// The returned string must be freed with `xmloxide_free_string`. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_node_text_content( + doc: *const Document, + node: u32, +) -> *mut c_char { + let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { + return std::ptr::null_mut(); + }; + let text = doc.text_content(node_id); + to_c_string(&text) +} + +/// Returns the namespace URI of an element node, or null if none. +/// +/// The returned string must be freed with `xmloxide_free_string`. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_node_namespace(doc: *const Document, node: u32) -> *mut c_char { + let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { + return std::ptr::null_mut(); + }; + match doc.node_namespace(node_id) { + Some(ns) => to_c_string(ns), + None => std::ptr::null_mut(), + } +} + +/// Returns the value of an attribute by name on an element node. +/// +/// Returns null if the attribute is not present. The returned string +/// must be freed with `xmloxide_free_string`. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. `name` must be a valid +/// null-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_node_attribute( + doc: *const Document, + node: u32, + name: *const c_char, +) -> *mut c_char { + let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { + return std::ptr::null_mut(); + }; + if name.is_null() { + return std::ptr::null_mut(); + } + // SAFETY: Null check above. Caller guarantees `name` is a valid null-terminated string. + let c_name = unsafe { std::ffi::CStr::from_ptr(name) }; + let Ok(name_str) = c_name.to_str() else { + return std::ptr::null_mut(); + }; + match doc.attribute(node_id, name_str) { + Some(val) => to_c_string(val), + None => std::ptr::null_mut(), + } +} + +/// Returns the number of attributes on an element node. +/// +/// Returns 0 for non-element nodes. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_node_attribute_count(doc: *const Document, node: u32) -> usize { + let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { + return 0; + }; + doc.attributes(node_id).len() +} + +/// Returns the name of the attribute at the given index. +/// +/// Returns null if the index is out of range. The returned string must +/// be freed with `xmloxide_free_string`. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_node_attribute_name_at( + doc: *const Document, + node: u32, + index: usize, +) -> *mut c_char { + let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { + return std::ptr::null_mut(); + }; + let attrs = doc.attributes(node_id); + match attrs.get(index) { + Some(attr) => to_c_string(&attr.name), + None => std::ptr::null_mut(), + } +} + +/// Returns the value of the attribute at the given index. +/// +/// Returns null if the index is out of range. The returned string must +/// be freed with `xmloxide_free_string`. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_node_attribute_value_at( + doc: *const Document, + node: u32, + index: usize, +) -> *mut c_char { + let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { + return std::ptr::null_mut(); + }; + let attrs = doc.attributes(node_id); + match attrs.get(index) { + Some(attr) => to_c_string(&attr.value), + None => std::ptr::null_mut(), + } +} + +/// Helper to safely dereference a mutable document pointer and node id. +unsafe fn doc_and_node_mut( + doc: *mut Document, + raw_node: u32, +) -> Option<(&'static mut Document, NodeId)> { + if doc.is_null() { + return None; + } + // SAFETY: Null check above. Caller guarantees `doc` is a valid pointer from a parse function. + let doc = unsafe { &mut *doc }; + let node_id = NodeId::from_raw(raw_node)?; + Some((doc, node_id)) +} + +/// Creates a new element node and returns its id (0 on failure). +/// +/// The node is detached — use `xmloxide_append_child` to add it to the tree. +/// The returned node is owned by the document and freed when the document is freed. +/// +/// # Safety +/// +/// `doc` must be a valid mutable document pointer. `name` must be a valid +/// null-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_create_element(doc: *mut Document, name: *const c_char) -> u32 { + if doc.is_null() || name.is_null() { + return 0; + } + // SAFETY: Null checks above. Caller guarantees valid pointers. + let doc = unsafe { &mut *doc }; + let c_name = unsafe { std::ffi::CStr::from_ptr(name) }; + let Ok(name_str) = c_name.to_str() else { + return 0; + }; + let node = doc.create_node(NodeKind::Element { + name: name_str.to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + node.into_raw() +} + +/// Creates a new text node and returns its id (0 on failure). +/// +/// # Safety +/// +/// `doc` must be a valid mutable document pointer. `content` must be a valid +/// null-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_create_text(doc: *mut Document, content: *const c_char) -> u32 { + if doc.is_null() || content.is_null() { + return 0; + } + // SAFETY: Null checks above. + let doc = unsafe { &mut *doc }; + let c_content = unsafe { std::ffi::CStr::from_ptr(content) }; + let Ok(text) = c_content.to_str() else { + return 0; + }; + let node = doc.create_node(NodeKind::Text { + content: text.to_string(), + }); + node.into_raw() +} + +/// Creates a new comment node and returns its id (0 on failure). +/// +/// # Safety +/// +/// `doc` must be a valid mutable document pointer. `content` must be a valid +/// null-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_create_comment( + doc: *mut Document, + content: *const c_char, +) -> u32 { + if doc.is_null() || content.is_null() { + return 0; + } + // SAFETY: Null checks above. + let doc = unsafe { &mut *doc }; + let c_content = unsafe { std::ffi::CStr::from_ptr(content) }; + let Ok(text) = c_content.to_str() else { + return 0; + }; + let node = doc.create_node(NodeKind::Comment { + content: text.to_string(), + }); + node.into_raw() +} + +/// Appends a child node to a parent. Returns 1 on success, 0 on failure. +/// +/// # Safety +/// +/// `doc` must be a valid mutable document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_append_child(doc: *mut Document, parent: u32, child: u32) -> i32 { + let Some((doc, parent_id)) = (unsafe { doc_and_node_mut(doc, parent) }) else { + return 0; + }; + let Some(child_id) = NodeId::from_raw(child) else { + return 0; + }; + doc.append_child(parent_id, child_id); + 1 +} + +/// Removes a node from the tree. Returns 1 on success, 0 on failure. +/// +/// The node remains in the arena but is detached from the tree. +/// +/// # Safety +/// +/// `doc` must be a valid mutable document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_remove_node(doc: *mut Document, node: u32) -> i32 { + let Some((doc, node_id)) = (unsafe { doc_and_node_mut(doc, node) }) else { + return 0; + }; + doc.remove_node(node_id); + 1 +} + +/// Deep-clones a node and its descendants. Returns the new node id (0 on failure). +/// +/// # Safety +/// +/// `doc` must be a valid mutable document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_clone_node(doc: *mut Document, node: u32, deep: i32) -> u32 { + let Some((doc, node_id)) = (unsafe { doc_and_node_mut(doc, node) }) else { + return 0; + }; + let cloned = doc.clone_node(node_id, deep != 0); + cloned.into_raw() +} + +/// Sets the text content of a node. Returns 1 on success, 0 on failure. +/// +/// For text, CDATA, and comment nodes, updates the content directly. +/// For element nodes, removes all children and replaces with a text node. +/// +/// # Safety +/// +/// `doc` must be a valid mutable document pointer. `content` must be a valid +/// null-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_set_text_content( + doc: *mut Document, + node: u32, + content: *const c_char, +) -> i32 { + if doc.is_null() || content.is_null() { + return 0; + } + let Some(node_id) = NodeId::from_raw(node) else { + return 0; + }; + // SAFETY: Null checks above. + let doc = unsafe { &mut *doc }; + let c_content = unsafe { std::ffi::CStr::from_ptr(content) }; + let Ok(text) = c_content.to_str() else { + return 0; + }; + i32::from(doc.set_text_content(node_id, text)) +} + +/// Sets an attribute on an element node. Returns 1 on success, 0 on failure. +/// +/// If the attribute already exists, its value is updated. +/// +/// # Safety +/// +/// `doc` must be a valid mutable document pointer. `name` and `value` must +/// be valid null-terminated UTF-8 strings. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_set_attribute( + doc: *mut Document, + node: u32, + name: *const c_char, + value: *const c_char, +) -> i32 { + if doc.is_null() || name.is_null() || value.is_null() { + return 0; + } + let Some(node_id) = NodeId::from_raw(node) else { + return 0; + }; + // SAFETY: Null checks above. + let doc = unsafe { &mut *doc }; + let c_name = unsafe { std::ffi::CStr::from_ptr(name) }; + let c_value = unsafe { std::ffi::CStr::from_ptr(value) }; + let Ok(name_str) = c_name.to_str() else { + return 0; + }; + let Ok(value_str) = c_value.to_str() else { + return 0; + }; + i32::from(doc.set_attribute(node_id, name_str, value_str)) +} + +/// Removes an attribute by name from an element node. +/// +/// Returns 1 if the attribute was removed, 0 if not found or not an element. +/// +/// # Safety +/// +/// `doc` must be a valid mutable document pointer. `name` must be a valid +/// null-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_remove_attribute( + doc: *mut Document, + node: u32, + name: *const c_char, +) -> i32 { + if doc.is_null() || name.is_null() { + return 0; + } + let Some(node_id) = NodeId::from_raw(node) else { + return 0; + }; + // SAFETY: Null checks above. + let doc = unsafe { &mut *doc }; + let c_name = unsafe { std::ffi::CStr::from_ptr(name) }; + let Ok(name_str) = c_name.to_str() else { + return 0; + }; + i32::from(doc.remove_attribute(node_id, name_str)) +} + +/// Inserts a node before a reference sibling. Returns 1 on success, 0 on failure. +/// +/// # Safety +/// +/// `doc` must be a valid mutable document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_insert_before( + doc: *mut Document, + reference: u32, + new_child: u32, +) -> i32 { + let Some((doc, ref_id)) = (unsafe { doc_and_node_mut(doc, reference) }) else { + return 0; + }; + let Some(child_id) = NodeId::from_raw(new_child) else { + return 0; + }; + doc.insert_before(ref_id, child_id); + 1 +} + +/// Returns the element with the given ID attribute, or 0 if not found. +/// +/// Note: the document's `id_map` must be populated first, typically by +/// running DTD validation with `xmloxide_validate_dtd`. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. `id` must be a valid +/// null-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_element_by_id(doc: *const Document, id: *const c_char) -> u32 { + if doc.is_null() || id.is_null() { + return 0; + } + // SAFETY: Null checks above. + let doc = unsafe { &*doc }; + let c_id = unsafe { std::ffi::CStr::from_ptr(id) }; + let Ok(id_str) = c_id.to_str() else { + return 0; + }; + node_id_to_raw(doc.element_by_id(id_str)) +} + +/// Inserts a node after a reference sibling. Returns 1 on success, 0 on failure. +/// +/// # Safety +/// +/// `doc` must be a valid mutable document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_insert_after( + doc: *mut Document, + reference: u32, + new_child: u32, +) -> i32 { + let Some((doc, ref_id)) = (unsafe { doc_and_node_mut(doc, reference) }) else { + return 0; + }; + let Some(child_id) = NodeId::from_raw(new_child) else { + return 0; + }; + doc.insert_after(ref_id, child_id); + 1 +} + +/// Replaces a node in the tree with another. Returns 1 on success, 0 on failure. +/// +/// The old node is detached and the new node takes its position. +/// +/// # Safety +/// +/// `doc` must be a valid mutable document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_replace_node( + doc: *mut Document, + old_node: u32, + new_node: u32, +) -> i32 { + let Some((doc, old_id)) = (unsafe { doc_and_node_mut(doc, old_node) }) else { + return 0; + }; + let Some(new_id) = NodeId::from_raw(new_node) else { + return 0; + }; + doc.replace_node(old_id, new_id); + 1 +} + +/// Creates a new processing instruction node and returns its id (0 on failure). +/// +/// # Safety +/// +/// `doc` must be a valid mutable document pointer. `target` must be a valid +/// null-terminated UTF-8 string. `data` may be null. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_create_pi( + doc: *mut Document, + target: *const c_char, + data: *const c_char, +) -> u32 { + if doc.is_null() || target.is_null() { + return 0; + } + // SAFETY: Null checks above. + let doc = unsafe { &mut *doc }; + let c_target = unsafe { std::ffi::CStr::from_ptr(target) }; + let Ok(target_str) = c_target.to_str() else { + return 0; + }; + let data_str = if data.is_null() { + None + } else { + let c_data = unsafe { std::ffi::CStr::from_ptr(data) }; + match c_data.to_str() { + Ok(s) => Some(s), + Err(_) => return 0, + } + }; + let node = doc.create_processing_instruction(target_str, data_str); + node.into_raw() +} + +/// Renames an element node. Returns 1 on success, 0 on failure. +/// +/// # Safety +/// +/// `doc` must be a valid mutable document pointer. `new_name` must be a valid +/// null-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_rename_element( + doc: *mut Document, + node: u32, + new_name: *const c_char, +) -> i32 { + if doc.is_null() || new_name.is_null() { + return 0; + } + let Some(node_id) = NodeId::from_raw(node) else { + return 0; + }; + // SAFETY: Null checks above. + let doc = unsafe { &mut *doc }; + let c_name = unsafe { std::ffi::CStr::from_ptr(new_name) }; + let Ok(name_str) = c_name.to_str() else { + return 0; + }; + i32::from(doc.rename_element(node_id, name_str)) +} + +/// Returns the namespace prefix of an element node, or null if none. +/// +/// For example, returns `"svg"` for `<svg:rect>`. +/// The returned string must be freed with `xmloxide_free_string`. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_node_prefix(doc: *const Document, node: u32) -> *mut c_char { + let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { + return std::ptr::null_mut(); + }; + match doc.node_prefix(node_id) { + Some(prefix) => to_c_string(prefix), + None => std::ptr::null_mut(), + } +} diff --git a/browser/vendor/xmloxide/src/ffi/validation.rs b/browser/vendor/xmloxide/src/ffi/validation.rs new file mode 100644 index 000000000..a41db9529 --- /dev/null +++ b/browser/vendor/xmloxide/src/ffi/validation.rs @@ -0,0 +1,467 @@ +//! Validation FFI functions (DTD, `RelaxNG`, XSD, Schematron). +#![allow(unsafe_code, clippy::missing_safety_doc)] + +use std::ffi::CStr; +use std::os::raw::c_char; + +use crate::tree::Document; +use crate::validation::dtd::{self, Dtd}; +use crate::validation::relaxng::{self, RelaxNgSchema}; +use crate::validation::schematron::{self, SchematronSchema}; +use crate::validation::xsd::{self, XsdSchema}; +use crate::validation::ValidationResult; + +use super::strings::to_c_string; +use super::{clear_last_error, set_last_error}; + +/// Parses a DTD from a null-terminated UTF-8 string. +/// +/// Returns a pointer to the DTD on success, or null on failure. +/// The returned DTD must be freed with [`xmloxide_free_dtd`]. +/// +/// # Safety +/// +/// `input` must be a valid null-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_parse_dtd(input: *const c_char) -> *mut Dtd { + clear_last_error(); + if input.is_null() { + set_last_error("null input pointer"); + return std::ptr::null_mut(); + } + // SAFETY: Null check above. Caller guarantees valid null-terminated string. + let c_str = unsafe { CStr::from_ptr(input) }; + let Ok(s) = c_str.to_str() else { + set_last_error("invalid UTF-8"); + return std::ptr::null_mut(); + }; + match dtd::parse_dtd(s) { + Ok(dtd) => Box::into_raw(Box::new(dtd)), + Err(e) => { + set_last_error(&e.message); + std::ptr::null_mut() + } + } +} + +/// Frees a DTD previously returned by `xmloxide_parse_dtd`. +/// +/// Passing null is safe and does nothing. +/// +/// # Safety +/// +/// `dtd` must have been returned by `xmloxide_parse_dtd`, or be null. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_free_dtd(dtd: *mut Dtd) { + if !dtd.is_null() { + // SAFETY: `dtd` was created by `Box::into_raw`, and is non-null. + unsafe { + drop(Box::from_raw(dtd)); + } + } +} + +/// Validates a document against a DTD. +/// +/// Returns a pointer to the validation result on success, or null on failure. +/// The returned result must be freed with [`xmloxide_free_validation_result`]. +/// +/// Note: DTD validation may populate the document's `id_map`, so the +/// document pointer must be mutable. +/// +/// # Safety +/// +/// `doc` must be a valid mutable document pointer. `dtd` must be a valid DTD pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_validate_dtd( + doc: *mut Document, + dtd: *const Dtd, +) -> *mut ValidationResult { + clear_last_error(); + if doc.is_null() || dtd.is_null() { + set_last_error("null pointer argument"); + return std::ptr::null_mut(); + } + // SAFETY: Null checks above. Caller guarantees valid pointers. + let doc = unsafe { &mut *doc }; + let dtd = unsafe { &*dtd }; + let result = dtd::validate(doc, dtd); + Box::into_raw(Box::new(result)) +} + +/// Parses a `RelaxNG` schema from a null-terminated UTF-8 XML string. +/// +/// Returns a pointer to the schema on success, or null on failure. +/// The returned schema must be freed with [`xmloxide_free_relaxng`]. +/// +/// # Safety +/// +/// `input` must be a valid null-terminated UTF-8 string containing a `RelaxNG` schema. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_parse_relaxng(input: *const c_char) -> *mut RelaxNgSchema { + clear_last_error(); + if input.is_null() { + set_last_error("null input pointer"); + return std::ptr::null_mut(); + } + // SAFETY: Null check above. Caller guarantees valid null-terminated string. + let c_str = unsafe { CStr::from_ptr(input) }; + let Ok(s) = c_str.to_str() else { + set_last_error("invalid UTF-8"); + return std::ptr::null_mut(); + }; + match relaxng::parse_relaxng(s) { + Ok(schema) => Box::into_raw(Box::new(schema)), + Err(e) => { + set_last_error(&e.message); + std::ptr::null_mut() + } + } +} + +/// Frees a `RelaxNG` schema previously returned by `xmloxide_parse_relaxng`. +/// +/// Passing null is safe and does nothing. +/// +/// # Safety +/// +/// `schema` must have been returned by `xmloxide_parse_relaxng`, or be null. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_free_relaxng(schema: *mut RelaxNgSchema) { + if !schema.is_null() { + // SAFETY: `schema` was created by `Box::into_raw`, and is non-null. + unsafe { + drop(Box::from_raw(schema)); + } + } +} + +/// Validates a document against a `RelaxNG` schema. +/// +/// Returns a pointer to the validation result on success, or null on failure. +/// The returned result must be freed with [`xmloxide_free_validation_result`]. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. `schema` must be a valid `RelaxNG` schema pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_validate_relaxng( + doc: *const Document, + schema: *const RelaxNgSchema, +) -> *mut ValidationResult { + clear_last_error(); + if doc.is_null() || schema.is_null() { + set_last_error("null pointer argument"); + return std::ptr::null_mut(); + } + // SAFETY: Null checks above. Caller guarantees valid pointers. + let doc = unsafe { &*doc }; + let schema = unsafe { &*schema }; + let result = relaxng::validate(doc, schema); + Box::into_raw(Box::new(result)) +} + +/// Parses an XSD schema from a null-terminated UTF-8 XML string. +/// +/// Returns a pointer to the schema on success, or null on failure. +/// The returned schema must be freed with [`xmloxide_free_xsd`]. +/// +/// # Safety +/// +/// `input` must be a valid null-terminated UTF-8 string containing an XSD schema. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_parse_xsd(input: *const c_char) -> *mut XsdSchema { + clear_last_error(); + if input.is_null() { + set_last_error("null input pointer"); + return std::ptr::null_mut(); + } + // SAFETY: Null check above. Caller guarantees valid null-terminated string. + let c_str = unsafe { CStr::from_ptr(input) }; + let Ok(s) = c_str.to_str() else { + set_last_error("invalid UTF-8"); + return std::ptr::null_mut(); + }; + match xsd::parse_xsd(s) { + Ok(schema) => Box::into_raw(Box::new(schema)), + Err(e) => { + set_last_error(&e.message); + std::ptr::null_mut() + } + } +} + +/// Frees an XSD schema previously returned by `xmloxide_parse_xsd`. +/// +/// Passing null is safe and does nothing. +/// +/// # Safety +/// +/// `schema` must have been returned by `xmloxide_parse_xsd`, or be null. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_free_xsd(schema: *mut XsdSchema) { + if !schema.is_null() { + // SAFETY: `schema` was created by `Box::into_raw`, and is non-null. + unsafe { + drop(Box::from_raw(schema)); + } + } +} + +/// Validates a document against an XSD schema. +/// +/// Returns a pointer to the validation result on success, or null on failure. +/// The returned result must be freed with [`xmloxide_free_validation_result`]. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. `schema` must be a valid XSD schema pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_validate_xsd( + doc: *const Document, + schema: *const XsdSchema, +) -> *mut ValidationResult { + clear_last_error(); + if doc.is_null() || schema.is_null() { + set_last_error("null pointer argument"); + return std::ptr::null_mut(); + } + // SAFETY: Null checks above. Caller guarantees valid pointers. + let doc = unsafe { &*doc }; + let schema = unsafe { &*schema }; + let result = xsd::validate_xsd(doc, schema); + Box::into_raw(Box::new(result)) +} + +/// Returns whether the validation result indicates a valid document. +/// +/// Returns 1 for valid, 0 for invalid or null. +/// +/// # Safety +/// +/// `result` must be a valid validation result pointer, or null. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_validation_is_valid(result: *const ValidationResult) -> i32 { + if result.is_null() { + return 0; + } + // SAFETY: Null check above. Caller guarantees `result` is a valid pointer. + let result = unsafe { &*result }; + i32::from(result.is_valid) +} + +/// Returns the number of validation errors. +/// +/// Returns 0 if the result is null. +/// +/// # Safety +/// +/// `result` must be a valid validation result pointer, or null. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_validation_error_count(result: *const ValidationResult) -> usize { + if result.is_null() { + return 0; + } + // SAFETY: Null check above. + let result = unsafe { &*result }; + result.errors.len() +} + +/// Returns the error message at the given index. +/// +/// Returns null if the index is out of range. The returned string must be +/// freed with `xmloxide_free_string`. +/// +/// # Safety +/// +/// `result` must be a valid validation result pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_validation_error_message( + result: *const ValidationResult, + index: usize, +) -> *mut c_char { + if result.is_null() { + return std::ptr::null_mut(); + } + // SAFETY: Null check above. + let result = unsafe { &*result }; + match result.errors.get(index) { + Some(err) => to_c_string(&err.to_string()), + None => std::ptr::null_mut(), + } +} + +/// Returns the number of validation warnings. +/// +/// Returns 0 if the result is null. +/// +/// # Safety +/// +/// `result` must be a valid validation result pointer, or null. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_validation_warning_count( + result: *const ValidationResult, +) -> usize { + if result.is_null() { + return 0; + } + // SAFETY: Null check above. + let result = unsafe { &*result }; + result.warnings.len() +} + +/// Returns the warning message at the given index. +/// +/// Returns null if the index is out of range. The returned string must be +/// freed with `xmloxide_free_string`. +/// +/// # Safety +/// +/// `result` must be a valid validation result pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_validation_warning_message( + result: *const ValidationResult, + index: usize, +) -> *mut c_char { + if result.is_null() { + return std::ptr::null_mut(); + } + // SAFETY: Null check above. + let result = unsafe { &*result }; + match result.warnings.get(index) { + Some(warn) => to_c_string(&warn.to_string()), + None => std::ptr::null_mut(), + } +} + +/// Parses an ISO Schematron schema from a null-terminated UTF-8 XML string. +/// +/// Returns a pointer to the schema on success, or null on failure. +/// The returned schema must be freed with [`xmloxide_free_schematron`]. +/// +/// # Safety +/// +/// `input` must be a valid null-terminated UTF-8 string containing a Schematron schema. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_parse_schematron(input: *const c_char) -> *mut SchematronSchema { + clear_last_error(); + if input.is_null() { + set_last_error("null input pointer"); + return std::ptr::null_mut(); + } + // SAFETY: Null check above. Caller guarantees valid null-terminated string. + let c_str = unsafe { CStr::from_ptr(input) }; + let Ok(s) = c_str.to_str() else { + set_last_error("invalid UTF-8"); + return std::ptr::null_mut(); + }; + match schematron::parse_schematron(s) { + Ok(schema) => Box::into_raw(Box::new(schema)), + Err(e) => { + set_last_error(&e.message); + std::ptr::null_mut() + } + } +} + +/// Frees a Schematron schema previously returned by `xmloxide_parse_schematron`. +/// +/// Passing null is safe and does nothing. +/// +/// # Safety +/// +/// `schema` must have been returned by `xmloxide_parse_schematron`, or be null. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_free_schematron(schema: *mut SchematronSchema) { + if !schema.is_null() { + // SAFETY: `schema` was created by `Box::into_raw`, and is non-null. + unsafe { + drop(Box::from_raw(schema)); + } + } +} + +/// Validates a document against an ISO Schematron schema. +/// +/// Returns a pointer to the validation result on success, or null on failure. +/// The returned result must be freed with [`xmloxide_free_validation_result`]. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. `schema` must be a valid Schematron schema pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_validate_schematron( + doc: *const Document, + schema: *const SchematronSchema, +) -> *mut ValidationResult { + clear_last_error(); + if doc.is_null() || schema.is_null() { + set_last_error("null pointer argument"); + return std::ptr::null_mut(); + } + // SAFETY: Null checks above. Caller guarantees valid pointers. + let doc = unsafe { &*doc }; + let schema = unsafe { &*schema }; + let result = schematron::validate_schematron(doc, schema); + Box::into_raw(Box::new(result)) +} + +/// Validates a document against a Schematron schema using a specific phase. +/// +/// `phase` is the name of the phase to activate (null-terminated UTF-8). +/// If `phase` is null, all patterns are active (equivalent to +/// [`xmloxide_validate_schematron`]). +/// +/// Returns a pointer to the validation result on success, or null on failure. +/// The returned result must be freed with [`xmloxide_free_validation_result`]. +/// +/// # Safety +/// +/// `doc` and `schema` must be valid pointers. `phase` must be a valid +/// null-terminated UTF-8 string, or null. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_validate_schematron_with_phase( + doc: *const Document, + schema: *const SchematronSchema, + phase: *const c_char, +) -> *mut ValidationResult { + clear_last_error(); + if doc.is_null() || schema.is_null() { + set_last_error("null pointer argument"); + return std::ptr::null_mut(); + } + // SAFETY: Null checks above. Caller guarantees valid pointers. + let doc = unsafe { &*doc }; + let schema = unsafe { &*schema }; + + if phase.is_null() { + let result = schematron::validate_schematron(doc, schema); + return Box::into_raw(Box::new(result)); + } + + // SAFETY: Null check above. + let phase_raw = unsafe { CStr::from_ptr(phase) }; + let Ok(phase_name) = phase_raw.to_str() else { + set_last_error("invalid UTF-8 in phase name"); + return std::ptr::null_mut(); + }; + let result = schematron::validate_schematron_with_phase(doc, schema, phase_name); + Box::into_raw(Box::new(result)) +} + +/// Frees a validation result previously returned by a validate function. +/// +/// Passing null is safe and does nothing. +/// +/// # Safety +/// +/// `result` must have been returned by a validate function, or be null. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_free_validation_result(result: *mut ValidationResult) { + if !result.is_null() { + // SAFETY: `result` was created by `Box::into_raw`, and is non-null. + unsafe { + drop(Box::from_raw(result)); + } + } +} diff --git a/browser/vendor/xmloxide/src/ffi/xinclude.rs b/browser/vendor/xmloxide/src/ffi/xinclude.rs new file mode 100644 index 000000000..74bfedf83 --- /dev/null +++ b/browser/vendor/xmloxide/src/ffi/xinclude.rs @@ -0,0 +1,37 @@ +//! `XInclude` processing FFI functions. +#![allow(unsafe_code, clippy::missing_safety_doc)] + +use crate::tree::Document; +use crate::xinclude::{self, XIncludeOptions}; + +use super::{clear_last_error, set_last_error}; + +/// Processes `XInclude` elements in a document using file-based resolution. +/// +/// Returns the number of successful inclusions, or -1 on failure. +/// On failure, call [`xmloxide_last_error`](super::xmloxide_last_error) for details. +/// +/// The resolver reads included files from the filesystem relative to the +/// working directory. +/// +/// # Safety +/// +/// `doc` must be a valid mutable document pointer. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_process_xincludes(doc: *mut Document) -> i32 { + clear_last_error(); + if doc.is_null() { + set_last_error("null document pointer"); + return -1; + } + // SAFETY: Null check above. + let doc = unsafe { &mut *doc }; + let resolver = |href: &str| std::fs::read_to_string(href).ok(); + let result = xinclude::process_xincludes(doc, resolver, &XIncludeOptions::default()); + let count = i32::try_from(result.inclusions).unwrap_or(i32::MAX); + if !result.errors.is_empty() { + let msgs: Vec<String> = result.errors.iter().map(|e| e.message.clone()).collect(); + set_last_error(&msgs.join("; ")); + } + count +} diff --git a/browser/vendor/xmloxide/src/ffi/xpath.rs b/browser/vendor/xmloxide/src/ffi/xpath.rs new file mode 100644 index 000000000..85bc628de --- /dev/null +++ b/browser/vendor/xmloxide/src/ffi/xpath.rs @@ -0,0 +1,305 @@ +//! `XPath` evaluation FFI functions. +#![allow(unsafe_code, clippy::missing_safety_doc)] + +use std::ffi::CStr; +use std::os::raw::{c_char, c_int}; + +use crate::tree::{Document, NodeId}; +use crate::xpath::{XPathNode, XPathValue}; + +use super::strings::to_c_string; +use super::{clear_last_error, set_last_error}; + +/// `XPath` result type constants. +pub const XMLOXIDE_XPATH_NODESET: i32 = 1; +/// `XPath` boolean result type. +pub const XMLOXIDE_XPATH_BOOLEAN: i32 = 2; +/// `XPath` number result type. +pub const XMLOXIDE_XPATH_NUMBER: i32 = 3; +/// `XPath` string result type. +pub const XMLOXIDE_XPATH_STRING: i32 = 4; + +/// Evaluates an `XPath` expression against a context node. +/// +/// Returns a pointer to the result on success, or null on failure. +/// On failure, call [`xmloxide_last_error`](super::xmloxide_last_error) for details. +/// +/// The returned result must be freed with [`xmloxide_xpath_free_result`]. +/// +/// # Safety +/// +/// `doc` must be a valid document pointer. `expr` must be a valid +/// null-terminated UTF-8 string. `context_node` must be a valid node +/// id within the document (use 0 to use the document root). +#[no_mangle] +pub unsafe extern "C" fn xmloxide_xpath_eval( + doc: *const Document, + context_node: u32, + expr: *const c_char, +) -> *mut XPathValue { + clear_last_error(); + if doc.is_null() || expr.is_null() { + set_last_error("null pointer argument"); + return std::ptr::null_mut(); + } + // SAFETY: Null checks above. Caller guarantees both pointers are valid. + let doc = unsafe { &*doc }; + // SAFETY: Null check above. Caller guarantees `expr` is a valid null-terminated string. + let c_expr = unsafe { CStr::from_ptr(expr) }; + let Ok(expr_str) = c_expr.to_str() else { + set_last_error("invalid UTF-8 in XPath expression"); + return std::ptr::null_mut(); + }; + + let ctx_node = if context_node == 0 { + doc.root() + } else if let Some(id) = NodeId::from_raw(context_node) { + id + } else { + set_last_error("invalid node id"); + return std::ptr::null_mut(); + }; + + match crate::xpath::evaluate(doc, ctx_node, expr_str) { + Ok(val) => Box::into_raw(Box::new(val)), + Err(e) => { + set_last_error(&format!("{e}")); + std::ptr::null_mut() + } + } +} + +/// Returns the type of an `XPath` result. +/// +/// Returns one of the `XMLOXIDE_XPATH_*` constants, or -1 on error. +/// +/// # Safety +/// +/// `result` must be a valid pointer returned by `xmloxide_xpath_eval`. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_xpath_result_type(result: *const XPathValue) -> i32 { + if result.is_null() { + return -1; + } + // SAFETY: Null check above. Caller guarantees `result` is a valid pointer from `xmloxide_xpath_eval`. + let val = unsafe { &*result }; + match val { + XPathValue::NodeSet(_) => XMLOXIDE_XPATH_NODESET, + XPathValue::Boolean(_) => XMLOXIDE_XPATH_BOOLEAN, + XPathValue::Number(_) => XMLOXIDE_XPATH_NUMBER, + XPathValue::String(_) => XMLOXIDE_XPATH_STRING, + } +} + +/// Returns the boolean value of an `XPath` result. +/// +/// Converts non-boolean results using `XPath` type coercion rules. +/// +/// # Safety +/// +/// `result` must be a valid pointer returned by `xmloxide_xpath_eval`. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_xpath_result_boolean(result: *const XPathValue) -> i32 { + if result.is_null() { + return 0; + } + // SAFETY: Null check above. Caller guarantees `result` is a valid pointer from `xmloxide_xpath_eval`. + let val = unsafe { &*result }; + i32::from(val.to_boolean()) +} + +/// Returns the numeric value of an `XPath` result. +/// +/// Converts non-number results using `XPath` type coercion rules. +/// +/// # Safety +/// +/// `result` must be a valid pointer returned by `xmloxide_xpath_eval`. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_xpath_result_number(result: *const XPathValue) -> f64 { + if result.is_null() { + return f64::NAN; + } + // SAFETY: Null check above. Caller guarantees `result` is a valid pointer from `xmloxide_xpath_eval`. + let val = unsafe { &*result }; + val.to_number() +} + +/// Returns the string value of an `XPath` result. +/// +/// Converts non-string results using `XPath` type coercion rules. +/// The returned string must be freed with `xmloxide_free_string`. +/// +/// # Safety +/// +/// `result` must be a valid pointer returned by `xmloxide_xpath_eval`. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_xpath_result_string(result: *const XPathValue) -> *mut c_char { + if result.is_null() { + return std::ptr::null_mut(); + } + // SAFETY: Null check above. Caller guarantees `result` is a valid pointer from `xmloxide_xpath_eval`. + let val = unsafe { &*result }; + to_c_string(&val.to_xpath_string()) +} + +/// Returns the number of nodes in an `XPath` nodeset result. +/// +/// Returns 0 if the result is not a nodeset. +/// +/// # Safety +/// +/// `result` must be a valid pointer returned by `xmloxide_xpath_eval`. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_xpath_nodeset_count(result: *const XPathValue) -> usize { + if result.is_null() { + return 0; + } + // SAFETY: Null check above. Caller guarantees `result` is a valid pointer from `xmloxide_xpath_eval`. + let val = unsafe { &*result }; + match val { + XPathValue::NodeSet(nodes) => nodes.len(), + _ => 0, + } +} + +/// Returns the node id at the given index in an `XPath` nodeset result. +/// +/// For attribute nodes, returns the id of the owner element (use +/// `xmloxide_xpath_nodeset_item_is_attribute`, +/// `xmloxide_xpath_nodeset_item_attr_name`, and +/// `xmloxide_xpath_nodeset_item_attr_value` to inspect the attribute). +/// +/// Returns 0 if the result is not a nodeset or the index is out of bounds. +/// +/// # Safety +/// +/// `result` must be a valid pointer returned by `xmloxide_xpath_eval`. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_xpath_nodeset_item( + result: *const XPathValue, + index: usize, +) -> u32 { + if result.is_null() { + return 0; + } + // SAFETY: Null check above. Caller guarantees `result` is a valid pointer from `xmloxide_xpath_eval`. + let val = unsafe { &*result }; + match val { + XPathValue::NodeSet(nodes) => nodes.get(index).map_or(0, |n| n.anchor().into_raw()), + _ => 0, + } +} + +/// Returns 1 if the nodeset entry at `index` is an attribute node, 0 +/// otherwise (including out-of-bounds and non-nodeset results). +/// +/// # Safety +/// +/// `result` must be a valid pointer returned by `xmloxide_xpath_eval`. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_xpath_nodeset_item_is_attribute( + result: *const XPathValue, + index: usize, +) -> c_int { + if result.is_null() { + return 0; + } + // SAFETY: Null check above. Caller guarantees `result` is a valid pointer from `xmloxide_xpath_eval`. + let val = unsafe { &*result }; + match val { + XPathValue::NodeSet(nodes) => { + c_int::from(nodes.get(index).is_some_and(|n| n.is_attribute())) + } + _ => 0, + } +} + +/// Returns the qualified name of the attribute at `index` in a nodeset +/// result, or null if the entry is not an attribute. +/// +/// The returned string must be freed with `xmloxide_free_string`. +/// +/// # Safety +/// +/// `result` must be a valid pointer returned by `xmloxide_xpath_eval`, and +/// `doc` must be the document the result was evaluated against. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_xpath_nodeset_item_attr_name( + doc: *const Document, + result: *const XPathValue, + index: usize, +) -> *mut c_char { + // SAFETY: Null checks below; caller guarantees validity per the contract. + let (doc, val) = unsafe { + match (doc.as_ref(), result.as_ref()) { + (Some(d), Some(v)) => (d, v), + _ => return std::ptr::null_mut(), + } + }; + let XPathValue::NodeSet(nodes) = val else { + return std::ptr::null_mut(); + }; + let Some(&XPathNode::Attribute { owner, index }) = nodes.get(index) else { + return std::ptr::null_mut(); + }; + let Some(attr) = doc.attributes(owner).get(index as usize) else { + return std::ptr::null_mut(); + }; + let qname = match &attr.prefix { + Some(prefix) => format!("{prefix}:{}", attr.name), + None => attr.name.clone(), + }; + to_c_string(&qname) +} + +/// Returns the value of the attribute at `index` in a nodeset result, or +/// null if the entry is not an attribute. +/// +/// The returned string must be freed with `xmloxide_free_string`. +/// +/// # Safety +/// +/// `result` must be a valid pointer returned by `xmloxide_xpath_eval`, and +/// `doc` must be the document the result was evaluated against. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_xpath_nodeset_item_attr_value( + doc: *const Document, + result: *const XPathValue, + index: usize, +) -> *mut c_char { + // SAFETY: Null checks below; caller guarantees validity per the contract. + let (doc, val) = unsafe { + match (doc.as_ref(), result.as_ref()) { + (Some(d), Some(v)) => (d, v), + _ => return std::ptr::null_mut(), + } + }; + let XPathValue::NodeSet(nodes) = val else { + return std::ptr::null_mut(); + }; + let Some(&XPathNode::Attribute { owner, index }) = nodes.get(index) else { + return std::ptr::null_mut(); + }; + let Some(attr) = doc.attributes(owner).get(index as usize) else { + return std::ptr::null_mut(); + }; + to_c_string(&attr.value) +} + +/// Frees an `XPath` result previously returned by `xmloxide_xpath_eval`. +/// +/// Passing null is safe and does nothing. +/// +/// # Safety +/// +/// `result` must have been returned by `xmloxide_xpath_eval`, or be null. +#[no_mangle] +pub unsafe extern "C" fn xmloxide_xpath_free_result(result: *mut XPathValue) { + if !result.is_null() { + // SAFETY: `result` was created by `Box::into_raw` in `xmloxide_xpath_eval`, and is non-null. + unsafe { + drop(Box::from_raw(result)); + } + } +} diff --git a/browser/vendor/xmloxide/src/html/entities.rs b/browser/vendor/xmloxide/src/html/entities.rs new file mode 100644 index 000000000..4997899ea --- /dev/null +++ b/browser/vendor/xmloxide/src/html/entities.rs @@ -0,0 +1,388 @@ +//! HTML named character references. +//! +//! This module provides a lookup table for HTML 4.01 named character references +//! (entities) such as `&nbsp;`, `&copy;`, `&mdash;`, etc. These extend beyond +//! the five predefined XML entities (`amp`, `lt`, `gt`, `apos`, `quot`). +//! +//! The table covers the entities defined in the HTML 4.01 specification +//! (section 24), which is what libxml2's HTML parser targets. +//! +//! See <https://www.w3.org/TR/html401/sgml/entities.html> + +/// Looks up an HTML named character reference and returns the corresponding +/// Unicode character(s) as a string slice. +/// +/// Returns `None` if the name is not a recognized HTML entity. The entity name +/// should be provided without the leading `&` and trailing `;`. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::html::entities::lookup_entity; +/// +/// assert_eq!(lookup_entity("nbsp"), Some("\u{00A0}")); +/// assert_eq!(lookup_entity("copy"), Some("\u{00A9}")); +/// assert_eq!(lookup_entity("nonexistent"), None); +/// ``` +pub fn lookup_entity(name: &str) -> Option<&'static str> { + // Binary search on the sorted entity table. + ENTITIES + .binary_search_by_key(&name, |&(n, _)| n) + .ok() + .map(|i| ENTITIES[i].1) +} + +/// Looks up the HTML named entity for a given character (reverse lookup). +/// +/// Returns `None` if no named entity exists for the character, or if the +/// character is one of the XML builtins (`&`, `<`, `>`, `'`, `"`) which +/// are handled separately by the escaping logic. +/// +/// Used by the HTML serializer to re-encode non-ASCII characters as their +/// named entity form (e.g., `©` → `&copy;`, `\u{00A0}` → `&nbsp;`). +/// +/// # Examples +/// +/// ``` +/// use xmloxide::html::entities::reverse_lookup_entity; +/// +/// assert_eq!(reverse_lookup_entity('\u{00A9}'), Some("copy")); +/// assert_eq!(reverse_lookup_entity('\u{00A0}'), Some("nbsp")); +/// assert_eq!(reverse_lookup_entity('A'), None); +/// ``` +pub fn reverse_lookup_entity(ch: char) -> Option<&'static str> { + let mut buf = [0u8; 4]; + let target = ch.encode_utf8(&mut buf); + for &(name, value) in ENTITIES { + if value == target { + return Some(name); + } + } + None +} + +/// The HTML 4.01 named character reference table, sorted by name for binary +/// search. Each entry is `(entity_name, replacement_str)`. +/// +/// This covers ISO 8859-1 characters, mathematical/Greek/symbolic characters, +/// and markup-significant characters defined in HTML 4.01. +static ENTITIES: &[(&str, &str)] = &[ + ("AElig", "\u{00C6}"), + ("Aacute", "\u{00C1}"), + ("Acirc", "\u{00C2}"), + ("Agrave", "\u{00C0}"), + ("Alpha", "\u{0391}"), + ("Aring", "\u{00C5}"), + ("Atilde", "\u{00C3}"), + ("Auml", "\u{00C4}"), + ("Beta", "\u{0392}"), + ("Ccedil", "\u{00C7}"), + ("Chi", "\u{03A7}"), + ("Dagger", "\u{2021}"), + ("Delta", "\u{0394}"), + ("ETH", "\u{00D0}"), + ("Eacute", "\u{00C9}"), + ("Ecirc", "\u{00CA}"), + ("Egrave", "\u{00C8}"), + ("Epsilon", "\u{0395}"), + ("Eta", "\u{0397}"), + ("Euml", "\u{00CB}"), + ("Gamma", "\u{0393}"), + ("Iacute", "\u{00CD}"), + ("Icirc", "\u{00CE}"), + ("Igrave", "\u{00CC}"), + ("Iota", "\u{0399}"), + ("Iuml", "\u{00CF}"), + ("Kappa", "\u{039A}"), + ("Lambda", "\u{039B}"), + ("Mu", "\u{039C}"), + ("Ntilde", "\u{00D1}"), + ("Nu", "\u{039D}"), + ("OElig", "\u{0152}"), + ("Oacute", "\u{00D3}"), + ("Ocirc", "\u{00D4}"), + ("Ograve", "\u{00D2}"), + ("Omega", "\u{03A9}"), + ("Omicron", "\u{039F}"), + ("Oslash", "\u{00D8}"), + ("Otilde", "\u{00D5}"), + ("Ouml", "\u{00D6}"), + ("Phi", "\u{03A6}"), + ("Pi", "\u{03A0}"), + ("Prime", "\u{2033}"), + ("Psi", "\u{03A8}"), + ("Rho", "\u{03A1}"), + ("Scaron", "\u{0160}"), + ("Sigma", "\u{03A3}"), + ("THORN", "\u{00DE}"), + ("Tau", "\u{03A4}"), + ("Theta", "\u{0398}"), + ("Uacute", "\u{00DA}"), + ("Ucirc", "\u{00DB}"), + ("Ugrave", "\u{00D9}"), + ("Upsilon", "\u{03A5}"), + ("Uuml", "\u{00DC}"), + ("Xi", "\u{039E}"), + ("Yacute", "\u{00DD}"), + ("Yuml", "\u{0178}"), + ("Zeta", "\u{0396}"), + ("aacute", "\u{00E1}"), + ("acirc", "\u{00E2}"), + ("acute", "\u{00B4}"), + ("aelig", "\u{00E6}"), + ("agrave", "\u{00E0}"), + ("alefsym", "\u{2135}"), + ("alpha", "\u{03B1}"), + ("amp", "&"), + ("and", "\u{2227}"), + ("ang", "\u{2220}"), + ("apos", "'"), + ("aring", "\u{00E5}"), + ("asymp", "\u{2248}"), + ("atilde", "\u{00E3}"), + ("auml", "\u{00E4}"), + ("bdquo", "\u{201E}"), + ("beta", "\u{03B2}"), + ("brvbar", "\u{00A6}"), + ("bull", "\u{2022}"), + ("cap", "\u{2229}"), + ("ccedil", "\u{00E7}"), + ("cedil", "\u{00B8}"), + ("cent", "\u{00A2}"), + ("chi", "\u{03C7}"), + ("circ", "\u{02C6}"), + ("clubs", "\u{2663}"), + ("cong", "\u{2245}"), + ("copy", "\u{00A9}"), + ("crarr", "\u{21B5}"), + ("cup", "\u{222A}"), + ("curren", "\u{00A4}"), + ("dArr", "\u{21D3}"), + ("dagger", "\u{2020}"), + ("darr", "\u{2193}"), + ("deg", "\u{00B0}"), + ("delta", "\u{03B4}"), + ("diams", "\u{2666}"), + ("divide", "\u{00F7}"), + ("eacute", "\u{00E9}"), + ("ecirc", "\u{00EA}"), + ("egrave", "\u{00E8}"), + ("empty", "\u{2205}"), + ("emsp", "\u{2003}"), + ("ensp", "\u{2002}"), + ("epsilon", "\u{03B5}"), + ("equiv", "\u{2261}"), + ("eta", "\u{03B7}"), + ("eth", "\u{00F0}"), + ("euml", "\u{00EB}"), + ("euro", "\u{20AC}"), + ("exist", "\u{2203}"), + ("fnof", "\u{0192}"), + ("forall", "\u{2200}"), + ("frac12", "\u{00BD}"), + ("frac14", "\u{00BC}"), + ("frac34", "\u{00BE}"), + ("frasl", "\u{2044}"), + ("gamma", "\u{03B3}"), + ("ge", "\u{2265}"), + ("gt", ">"), + ("hArr", "\u{21D4}"), + ("harr", "\u{2194}"), + ("hearts", "\u{2665}"), + ("hellip", "\u{2026}"), + ("iacute", "\u{00ED}"), + ("icirc", "\u{00EE}"), + ("iexcl", "\u{00A1}"), + ("igrave", "\u{00EC}"), + ("image", "\u{2111}"), + ("infin", "\u{221E}"), + ("int", "\u{222B}"), + ("iota", "\u{03B9}"), + ("iquest", "\u{00BF}"), + ("isin", "\u{2208}"), + ("iuml", "\u{00EF}"), + ("kappa", "\u{03BA}"), + ("lArr", "\u{21D0}"), + ("lambda", "\u{03BB}"), + ("lang", "\u{2329}"), + ("laquo", "\u{00AB}"), + ("larr", "\u{2190}"), + ("lceil", "\u{2308}"), + ("ldquo", "\u{201C}"), + ("le", "\u{2264}"), + ("lfloor", "\u{230A}"), + ("lowast", "\u{2217}"), + ("loz", "\u{25CA}"), + ("lrm", "\u{200E}"), + ("lsaquo", "\u{2039}"), + ("lsquo", "\u{2018}"), + ("lt", "<"), + ("macr", "\u{00AF}"), + ("mdash", "\u{2014}"), + ("micro", "\u{00B5}"), + ("middot", "\u{00B7}"), + ("minus", "\u{2212}"), + ("mu", "\u{03BC}"), + ("nabla", "\u{2207}"), + ("nbsp", "\u{00A0}"), + ("ndash", "\u{2013}"), + ("ne", "\u{2260}"), + ("ni", "\u{220B}"), + ("not", "\u{00AC}"), + ("notin", "\u{2209}"), + ("nsub", "\u{2284}"), + ("ntilde", "\u{00F1}"), + ("nu", "\u{03BD}"), + ("oacute", "\u{00F3}"), + ("ocirc", "\u{00F4}"), + ("oelig", "\u{0153}"), + ("ograve", "\u{00F2}"), + ("oline", "\u{203E}"), + ("omega", "\u{03C9}"), + ("omicron", "\u{03BF}"), + ("oplus", "\u{2295}"), + ("or", "\u{2228}"), + ("ordf", "\u{00AA}"), + ("ordm", "\u{00BA}"), + ("oslash", "\u{00F8}"), + ("otilde", "\u{00F5}"), + ("otimes", "\u{2297}"), + ("ouml", "\u{00F6}"), + ("para", "\u{00B6}"), + ("part", "\u{2202}"), + ("permil", "\u{2030}"), + ("perp", "\u{22A5}"), + ("phi", "\u{03C6}"), + ("pi", "\u{03C0}"), + ("piv", "\u{03D6}"), + ("plusmn", "\u{00B1}"), + ("pound", "\u{00A3}"), + ("prime", "\u{2032}"), + ("prod", "\u{220F}"), + ("prop", "\u{221D}"), + ("psi", "\u{03C8}"), + ("quot", "\""), + ("rArr", "\u{21D2}"), + ("radic", "\u{221A}"), + ("rang", "\u{232A}"), + ("raquo", "\u{00BB}"), + ("rarr", "\u{2192}"), + ("rceil", "\u{2309}"), + ("rdquo", "\u{201D}"), + ("real", "\u{211C}"), + ("reg", "\u{00AE}"), + ("rfloor", "\u{230B}"), + ("rho", "\u{03C1}"), + ("rlm", "\u{200F}"), + ("rsaquo", "\u{203A}"), + ("rsquo", "\u{2019}"), + ("sbquo", "\u{201A}"), + ("scaron", "\u{0161}"), + ("sdot", "\u{22C5}"), + ("sect", "\u{00A7}"), + ("shy", "\u{00AD}"), + ("sigma", "\u{03C3}"), + ("sigmaf", "\u{03C2}"), + ("sim", "\u{223C}"), + ("spades", "\u{2660}"), + ("sub", "\u{2282}"), + ("sube", "\u{2286}"), + ("sum", "\u{2211}"), + ("sup", "\u{2283}"), + ("sup1", "\u{00B9}"), + ("sup2", "\u{00B2}"), + ("sup3", "\u{00B3}"), + ("supe", "\u{2287}"), + ("szlig", "\u{00DF}"), + ("tau", "\u{03C4}"), + ("there4", "\u{2234}"), + ("theta", "\u{03B8}"), + ("thetasym", "\u{03D1}"), + ("thinsp", "\u{2009}"), + ("thorn", "\u{00FE}"), + ("tilde", "\u{02DC}"), + ("times", "\u{00D7}"), + ("trade", "\u{2122}"), + ("uArr", "\u{21D1}"), + ("uacute", "\u{00FA}"), + ("uarr", "\u{2191}"), + ("ucirc", "\u{00FB}"), + ("ugrave", "\u{00F9}"), + ("uml", "\u{00A8}"), + ("upsih", "\u{03D2}"), + ("upsilon", "\u{03C5}"), + ("uuml", "\u{00FC}"), + ("weierp", "\u{2118}"), + ("xi", "\u{03BE}"), + ("yacute", "\u{00FD}"), + ("yen", "\u{00A5}"), + ("yuml", "\u{00FF}"), + ("zeta", "\u{03B6}"), + ("zwj", "\u{200D}"), + ("zwnj", "\u{200C}"), +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lookup_basic_xml_entities() { + assert_eq!(lookup_entity("amp"), Some("&")); + assert_eq!(lookup_entity("lt"), Some("<")); + assert_eq!(lookup_entity("gt"), Some(">")); + assert_eq!(lookup_entity("apos"), Some("'")); + assert_eq!(lookup_entity("quot"), Some("\"")); + } + + #[test] + fn test_lookup_html_entities() { + assert_eq!(lookup_entity("nbsp"), Some("\u{00A0}")); + assert_eq!(lookup_entity("copy"), Some("\u{00A9}")); + assert_eq!(lookup_entity("reg"), Some("\u{00AE}")); + assert_eq!(lookup_entity("euro"), Some("\u{20AC}")); + assert_eq!(lookup_entity("mdash"), Some("\u{2014}")); + assert_eq!(lookup_entity("ndash"), Some("\u{2013}")); + assert_eq!(lookup_entity("hellip"), Some("\u{2026}")); + } + + #[test] + fn test_lookup_greek_entities() { + assert_eq!(lookup_entity("Alpha"), Some("\u{0391}")); + assert_eq!(lookup_entity("alpha"), Some("\u{03B1}")); + assert_eq!(lookup_entity("Omega"), Some("\u{03A9}")); + assert_eq!(lookup_entity("omega"), Some("\u{03C9}")); + assert_eq!(lookup_entity("pi"), Some("\u{03C0}")); + } + + #[test] + fn test_reverse_lookup() { + assert_eq!(reverse_lookup_entity('\u{00A9}'), Some("copy")); + assert_eq!(reverse_lookup_entity('\u{00A0}'), Some("nbsp")); + assert_eq!(reverse_lookup_entity('\u{0161}'), Some("scaron")); + assert_eq!(reverse_lookup_entity('\u{00E8}'), Some("egrave")); + assert_eq!(reverse_lookup_entity('\u{20AC}'), Some("euro")); + // ASCII characters should not have reverse lookups (handled separately) + assert_eq!(reverse_lookup_entity('A'), None); + assert_eq!(reverse_lookup_entity(' '), None); + } + + #[test] + fn test_lookup_nonexistent() { + assert_eq!(lookup_entity("nonexistent"), None); + assert_eq!(lookup_entity(""), None); + assert_eq!(lookup_entity("NBSP"), None); // case-sensitive + } + + #[test] + fn test_table_is_sorted() { + for window in ENTITIES.windows(2) { + assert!( + window[0].0 < window[1].0, + "entity table not sorted: {:?} should come before {:?}", + window[0].0, + window[1].0 + ); + } + } +} diff --git a/browser/vendor/xmloxide/src/html/mod.rs b/browser/vendor/xmloxide/src/html/mod.rs new file mode 100644 index 000000000..ac1cc9c19 --- /dev/null +++ b/browser/vendor/xmloxide/src/html/mod.rs @@ -0,0 +1,1773 @@ +//! Error-tolerant HTML parser. +//! +//! This module implements an error-tolerant HTML 4.01 parser, similar to +//! libxml2's `HTMLparser.c`. Unlike the strict XML parser, this parser handles +//! common HTML patterns that are technically malformed: +//! +//! - Missing closing tags (auto-closed based on HTML content model rules) +//! - Unquoted attribute values (`<div class=main>`) +//! - Void elements that never need closing (`<br>`, `<img>`, `<hr>`, etc.) +//! - Case-insensitive tag name matching +//! - Bare `&` characters (not just `&amp;`) +//! - Missing doctype +//! - Boolean attributes without values (`<input disabled>`) +//! +//! The parser produces the same `Document` tree structure as the XML parser. +//! +//! # Examples +//! +//! ``` +//! use xmloxide::html::parse_html; +//! +//! let doc = parse_html("<p>Hello <b>world</b>").unwrap(); +//! let root = doc.root_element().unwrap(); +//! assert_eq!(doc.node_name(root), Some("html")); +//! ``` + +pub mod entities; + +use crate::error::{ErrorSeverity, ParseDiagnostic, ParseError}; +use crate::parser::input::ParserInput; +use crate::tree::{Attribute, Document, NodeId, NodeKind}; + +/// Options controlling HTML parser behavior. +/// +/// Use the builder pattern to configure options: +/// +/// ``` +/// use xmloxide::html::HtmlParseOptions; +/// +/// let opts = HtmlParseOptions::default() +/// .recover(true) +/// .no_blanks(true) +/// .no_implied(true); +/// ``` +#[derive(Debug, Clone)] +#[allow(clippy::struct_excessive_bools)] +pub struct HtmlParseOptions { + /// If true, attempt to recover from errors and produce a partial tree. + /// This is always effectively true for HTML parsing since HTML is + /// inherently error-tolerant, but setting this to false makes the parser + /// stricter about certain issues. + pub recover: bool, + /// If true, strip ignorable whitespace-only text nodes. + pub no_blanks: bool, + /// If true, do not add implied `html`, `head`, and `body` elements. + pub no_implied: bool, + /// If true, suppress warning diagnostics. + pub no_warnings: bool, +} + +impl Default for HtmlParseOptions { + fn default() -> Self { + Self { + recover: true, + no_blanks: false, + no_implied: false, + no_warnings: false, + } + } +} + +impl HtmlParseOptions { + /// Enables or disables error recovery mode. + /// + /// When enabled, the parser attempts to produce a partial tree even when + /// it encounters malformed markup, collecting errors as diagnostics on + /// the resulting [`Document`]. HTML parsing is inherently error-tolerant, + /// but disabling this makes the parser stricter about certain issues. + /// Enabled by default. + #[must_use] + pub fn recover(mut self, yes: bool) -> Self { + self.recover = yes; + self + } + + /// Enables or disables stripping of blank (whitespace-only) text nodes. + /// + /// When enabled, text nodes that contain only whitespace (spaces, tabs, + /// newlines) are discarded during parsing. This is useful for reducing + /// tree size when whitespace between elements is not significant. + /// Disabled by default. + #[must_use] + pub fn no_blanks(mut self, yes: bool) -> Self { + self.no_blanks = yes; + self + } + + /// Enables or disables generation of implied `<html>`, `<head>`, and + /// `<body>` elements. + /// + /// By default, the HTML parser automatically wraps content in these + /// structural elements when they are missing (matching browser behavior). + /// When enabled, the parser omits these implied wrappers, producing a + /// tree that more closely reflects the literal input. + /// Disabled by default. + #[must_use] + pub fn no_implied(mut self, yes: bool) -> Self { + self.no_implied = yes; + self + } + + /// Enables or disables suppression of warning-level diagnostics. + /// + /// When enabled, only errors and fatal issues are recorded in + /// [`Document::diagnostics`]; warnings about non-critical issues (e.g., + /// missing optional closing tags) are silently discarded. + /// Disabled by default. + #[must_use] + pub fn no_warnings(mut self, yes: bool) -> Self { + self.no_warnings = yes; + self + } +} + +/// Parses an HTML string into a `Document` with default options. +/// +/// The parser is error-tolerant and will produce a tree even for malformed +/// HTML. Diagnostics about any issues found during parsing are stored in +/// `Document::diagnostics`. +/// +/// # Errors +/// +/// Returns `ParseError` only for truly unrecoverable errors (e.g., empty input +/// when recovery is disabled). +/// +/// # Examples +/// +/// ``` +/// use xmloxide::html::parse_html; +/// +/// let doc = parse_html("<p>Hello <b>world</b>").unwrap(); +/// let root = doc.root_element().unwrap(); +/// assert_eq!(doc.node_name(root), Some("html")); +/// ``` +pub fn parse_html(input: &str) -> Result<Document, ParseError> { + parse_html_with_options(input, &HtmlParseOptions::default()) +} + +/// Parses an HTML string into a `Document` with the given options. +/// +/// # Errors +/// +/// Returns `ParseError` if the input cannot be parsed and recovery mode is +/// disabled. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::html::{parse_html_with_options, HtmlParseOptions}; +/// +/// let opts = HtmlParseOptions::default().no_blanks(true); +/// let doc = parse_html_with_options("<html><body><p>Hi</p></body></html>", &opts).unwrap(); +/// ``` +pub fn parse_html_with_options( + input: &str, + options: &HtmlParseOptions, +) -> Result<Document, ParseError> { + let mut parser = HtmlParser::new(input, options); + parser.parse() +} + +// --- Void elements (elements that must not have content) --- +// See HTML 4.01 and common usage. These elements are self-closing. + +/// Returns true if the given tag name (lowercase) is a void element that +/// must not have content. +pub(crate) fn is_void_element(tag: &str) -> bool { + matches!( + tag, + "area" + | "base" + | "br" + | "col" + | "embed" + | "hr" + | "img" + | "input" + | "link" + | "meta" + | "param" + | "source" + | "track" + | "wbr" + | "basefont" + | "frame" + | "isindex" + ) +} + +/// HTML4 attributes libxml serializes in minimized form regardless of value. +pub(crate) fn is_boolean_attribute(name: &str) -> bool { + matches!( + name, + "checked" + | "compact" + | "declare" + | "defer" + | "disabled" + | "ismap" + | "multiple" + | "nohref" + | "noresize" + | "noshade" + | "nowrap" + | "readonly" + | "selected" + ) +} + +fn html_char_reference(value: u32) -> char { + match value { + 0 | 0xD800..=0xDFFF | 0x11_0000.. => '\u{FFFD}', + 0x80 => '\u{20AC}', + 0x82 => '\u{201A}', + 0x83 => '\u{0192}', + 0x84 => '\u{201E}', + 0x85 => '\u{2026}', + 0x86 => '\u{2020}', + 0x87 => '\u{2021}', + 0x88 => '\u{02C6}', + 0x89 => '\u{2030}', + 0x8A => '\u{0160}', + 0x8B => '\u{2039}', + 0x8C => '\u{0152}', + 0x8E => '\u{017D}', + 0x91 => '\u{2018}', + 0x92 => '\u{2019}', + 0x93 => '\u{201C}', + 0x94 => '\u{201D}', + 0x95 => '\u{2022}', + 0x96 => '\u{2013}', + 0x97 => '\u{2014}', + 0x98 => '\u{02DC}', + 0x99 => '\u{2122}', + 0x9A => '\u{0161}', + 0x9B => '\u{203A}', + 0x9C => '\u{0153}', + 0x9E => '\u{017E}', + 0x9F => '\u{0178}', + _ => char::from_u32(value).unwrap_or('\u{FFFD}'), + } +} + +/// Returns true if `tag` is an element whose opening auto-closes `open_tag`. +/// +/// For example, a `<p>` auto-closes a previous `<p>`, and a `<li>` auto-closes +/// a previous `<li>`. +/// +/// See HTML 4.01 DTD for the optional end tag rules. +fn auto_closes(open_tag: &str, tag: &str) -> bool { + match open_tag { + "a" => matches!(tag, "fieldset" | "table" | "td" | "th" | "a"), + "b" | "i" | "u" => matches!(tag, "p" | "td" | "th"), + "s" | "small" | "big" => tag == "p", + "span" | "font" => matches!(tag, "td" | "th"), + "p" => matches!( + tag, + "p" | "div" + | "ul" + | "ol" + | "dl" + | "pre" + | "table" + | "blockquote" + | "address" + | "h1" + | "h2" + | "h3" + | "h4" + | "h5" + | "h6" + | "hr" + | "form" + | "fieldset" + | "section" + | "article" + | "aside" + | "header" + | "footer" + | "nav" + | "figure" + | "figcaption" + | "main" + | "details" + | "summary" + | "tr" + | "td" + | "th" + ), + "li" => tag == "li", + "dt" => matches!(tag, "dt" | "dd"), + "dd" => tag == "dt", + "tr" => tag == "tr", + "td" => matches!(tag, "td" | "th" | "tr"), + "th" => matches!(tag, "td" | "th" | "tr"), + "thead" => matches!(tag, "tbody" | "tfoot"), + "tbody" => matches!(tag, "tbody" | "tfoot"), + "tfoot" => tag == "tbody", + "option" => matches!(tag, "option" | "optgroup"), + "optgroup" => tag == "optgroup", + "colgroup" => { + // colgroup is auto-closed by most things that are not col + tag != "col" && matches!(tag, "thead" | "tbody" | "tfoot" | "tr" | "colgroup") + } + "head" => matches!(tag, "body" | "frameset"), + _ => false, + } +} + +/// Returns true if `tag` is a raw text element whose content is not parsed +/// as HTML (script, style). +pub(crate) fn is_raw_text_element(tag: &str) -> bool { + matches!(tag, "script" | "style") +} + +/// Returns true if `tag` is an element that belongs in `<head>`. +fn is_head_content_element(tag: &str) -> bool { + matches!( + tag, + "title" | "meta" | "link" | "base" | "style" | "script" | "noscript" + ) +} + +// --- The HTML Parser --- + +/// The core HTML parser state machine. +/// +/// Implements a hand-rolled, error-tolerant parser for HTML 4.01 that produces +/// a `Document` tree. Unlike the XML parser, this parser: +/// - Normalizes tag names to lowercase +/// - Handles void elements (self-closing elements like `<br>`) +/// - Auto-closes elements based on HTML content model rules +/// - Accepts unquoted and boolean attributes +/// - Resolves HTML named character references +/// - Adds implied elements (html, head, body) when missing +struct HtmlParser<'a> { + /// Shared low-level input state (position, peek, advance, etc.). + input: ParserInput<'a>, + /// The document being built. + doc: Document, + /// Parser options. + options: HtmlParseOptions, + /// Stack of open element node IDs and their lowercase tag names. + open_elements: Vec<(NodeId, String)>, + /// Set when a fatal error (e.g. depth limit) occurs; stops parsing. + fatal_error: Option<ParseError>, +} + +impl<'a> HtmlParser<'a> { + fn new(input: &'a str, options: &HtmlParseOptions) -> Self { + let mut pi = ParserInput::new(input); + pi.set_recover(options.recover); + + Self { + input: pi, + doc: Document::new(), + options: options.clone(), + open_elements: Vec::new(), + fatal_error: None, + } + } + + /// Main parse entry point. Parses the entire HTML document. + fn parse(&mut self) -> Result<Document, ParseError> { + self.input.skip_whitespace(); + + // Track whether a DOCTYPE was found in the input + let mut has_doctype = false; + + // Parse optional DOCTYPE + if self.input.looking_at_ci(b"<!doctype") { + self.parse_doctype(); + self.input.skip_whitespace(); + has_doctype = true; + } + + // Parse the body content + self.parse_content(); + + // If a fatal error was recorded (e.g. depth limit), return it. + if let Some(err) = self.fatal_error.take() { + return Err(err); + } + + // Close any remaining open elements + while let Some((_, tag)) = self.open_elements.pop() { + self.push_warning(format!("unclosed element <{tag}> at end of document")); + } + + // Add default DOCTYPE if none was in the input and not disabled + if !has_doctype && !self.options.no_implied { + let doctype_id = self.doc.create_node(NodeKind::DocumentType { + name: "html".to_string(), + public_id: Some("-//W3C//DTD HTML 4.0 Transitional//EN".to_string()), + system_id: Some("http://www.w3.org/TR/REC-html40/loose.dtd".to_string()), + internal_subset: None, + }); + // Prepend DOCTYPE before the first child of the document root + let root = self.doc.root(); + self.doc.prepend_child(root, doctype_id); + } + + Ok(std::mem::take(&mut self.doc)) + } + + /// Ensures an implied `<html>` element exists at the document root. + /// Returns its `NodeId`. + fn ensure_html(&mut self) -> NodeId { + let root = self.doc.root(); + // Look for existing html element + for id in self.doc.children(root) { + if matches!(&self.doc.node(id).kind, NodeKind::Element { name, .. } if name == "html") { + return id; + } + } + // Create implied html + let html_id = self.doc.create_node(NodeKind::Element { + name: "html".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + self.doc.append_child(root, html_id); + self.open_elements.push((html_id, "html".to_string())); + html_id + } + + /// Ensures an implied `<body>` element exists under `<html>`. + /// Returns its `NodeId`. + fn ensure_body(&mut self) -> NodeId { + let html_id = self.ensure_html(); + // Look for existing body element + for id in self.doc.children(html_id) { + if matches!(&self.doc.node(id).kind, NodeKind::Element { name, .. } if name == "body") { + return id; + } + } + // Create implied body + let body_id = self.doc.create_node(NodeKind::Element { + name: "body".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + self.doc.append_child(html_id, body_id); + self.open_elements.push((body_id, "body".to_string())); + body_id + } + + /// Ensures an implied `<head>` element exists under `<html>`. + /// Returns its `NodeId`. + fn ensure_head(&mut self) -> NodeId { + let html_id = self.ensure_html(); + // Look for existing head element + for id in self.doc.children(html_id) { + if matches!(&self.doc.node(id).kind, NodeKind::Element { name, .. } if name == "head") { + return id; + } + } + // Create implied head. Insert before body if body exists, + // otherwise append to html. + let head_id = self.doc.create_node(NodeKind::Element { + name: "head".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + // Find body — if it exists, insert head before it + let body_id = self.doc.children(html_id).find(|&id| { + matches!(&self.doc.node(id).kind, NodeKind::Element { name, .. } if name == "body") + }); + if let Some(body) = body_id { + self.doc.insert_before(body, head_id); + } else { + self.doc.append_child(html_id, head_id); + } + head_id + } + + /// Returns the current insertion point (the innermost open element, or + /// the document root). + fn current_parent(&self) -> NodeId { + self.open_elements + .last() + .map_or_else(|| self.doc.root(), |&(id, _)| id) + } + + /// Parses the HTML content: elements, text, comments, etc. + fn parse_content(&mut self) { + while !self.input.at_end() && self.fatal_error.is_none() { + if self.input.looking_at(b"<!--") { + self.parse_comment(); + } else if self.input.looking_at_ci(b"<!doctype") { + // Ignore extra doctypes + self.skip_to_gt(); + } else if self.input.looking_at(b"</") { + self.parse_end_tag(); + } else if self.input.peek() == Some(b'<') + && self + .input + .peek_at(1) + .is_some_and(|b| b.is_ascii_alphabetic()) + { + self.parse_start_tag(); + } else if self.input.peek() == Some(b'<') && self.input.peek_at(1) == Some(b'!') { + // Could be <![if ...]> (IE conditional comment) or other malformed markup + if self.input.peek_at(2) == Some(b'[') { + self.skip_conditional_comment(); + } else { + self.push_warning("malformed markup".to_string()); + self.input.advance(1); + } + } else if self.input.peek() == Some(b'<') && self.input.peek_at(1) == Some(b'?') { + self.parse_processing_instruction(); + } else if self.input.peek() == Some(b'<') { + // Bare '<' not followed by alpha, '/', '!', or '?' — treat as text. + // We must consume it here to avoid an infinite loop, since + // parse_text() breaks on '<' without advancing. + self.input.advance(1); + if !self.options.no_implied && self.open_elements.is_empty() { + self.ensure_body(); + } + let parent = self.current_parent(); + // Merge with previous text node if possible + if let Some(last_child) = self.doc.last_child(parent) { + if let NodeKind::Text { content } = &mut self.doc.node_mut(last_child).kind { + content.push('<'); + continue; + } + } + let text_id = self.doc.create_node(NodeKind::Text { + content: "<".to_string(), + }); + self.doc.append_child(parent, text_id); + } else { + self.parse_text(); + } + } + } + + // --- DOCTYPE --- + + fn parse_doctype(&mut self) { + // Skip <!DOCTYPE (case-insensitive) + self.input.advance(9); // "<!DOCTYPE" or "<!doctype" + self.input.skip_whitespace(); + + // Read the root element name + let name = self.parse_tag_name(); + + self.input.skip_whitespace(); + + let mut system_id = None; + let mut public_id = None; + + if self.input.looking_at_ci(b"system") { + self.input.advance(6); + self.input.skip_whitespace(); + system_id = self.try_parse_quoted_value(); + self.input.skip_whitespace(); + } else if self.input.looking_at_ci(b"public") { + self.input.advance(6); + self.input.skip_whitespace(); + public_id = self.try_parse_quoted_value(); + self.input.skip_whitespace(); + system_id = self.try_parse_quoted_value(); + self.input.skip_whitespace(); + } + + // Skip to end of DOCTYPE + while !self.input.at_end() && self.input.peek() != Some(b'>') { + self.input.advance(1); + } + if !self.input.at_end() { + self.input.advance(1); // consume '>' + } + + let doctype_id = self.doc.create_node(NodeKind::DocumentType { + name, + system_id, + public_id, + internal_subset: None, + }); + self.doc.append_child(self.doc.root(), doctype_id); + } + + // --- Start Tag --- + + #[allow(clippy::too_many_lines)] + fn parse_start_tag(&mut self) { + let tag_start = self.input.save_position(); + self.input.advance(1); // consume '<' + if let Err(e) = self.input.increment_depth() { + self.fatal_error = Some(e); + return; + } + let tag = self.parse_tag_name(); + + if tag.is_empty() { + self.push_warning("empty tag name".to_string()); + self.input.decrement_depth(); + self.skip_to_gt(); + return; + } + + let lower_tag = tag.to_ascii_lowercase(); + + // Parse attributes + let attributes = self.parse_attributes(); + + self.input.skip_whitespace(); + + // libxml discards an unterminated start tag and everything consumed + // since '<' instead of synthesizing an element at EOF. + if self.input.at_end() { + self.input.restore_position(tag_start); + self.input.advance(self.input.remaining().len()); + self.input.decrement_depth(); + return; + } + + // Handle self-closing slash + let explicit_self_close = self.input.peek() == Some(b'/'); + if explicit_self_close { + self.input.advance(1); + } + + // Consume '>' + if self.input.peek() == Some(b'>') { + self.input.advance(1); + } else if !self.input.at_end() { + self.push_warning(format!("expected '>' after tag <{lower_tag}>")); + self.skip_to_gt(); + } + + // Handle structural elements by merging with existing implied elements + if !self.options.no_implied { + if lower_tag == "html" { + let html_id = self.ensure_html(); + self.merge_attributes(html_id, attributes); + // Ensure html is on the open_elements stack + if !self.open_elements.iter().any(|(_, t)| t == "html") { + self.open_elements.push((html_id, "html".to_string())); + } + self.input.decrement_depth(); + return; + } + if lower_tag == "head" { + let head_id = self.ensure_head(); + self.merge_attributes(head_id, attributes); + // Push head to open_elements so child elements go inside it + if !self.open_elements.iter().any(|(_, t)| t == "head") { + self.open_elements.push((head_id, "head".to_string())); + } + self.input.decrement_depth(); + return; + } + if lower_tag == "body" && !self.is_in_frameset() { + // Close head if open + self.close_head_if_open(); + let body_id = self.ensure_body(); + self.merge_attributes(body_id, attributes); + if !self.open_elements.iter().any(|(_, t)| t == "body") { + self.open_elements.push((body_id, "body".to_string())); + } + self.input.decrement_depth(); + return; + } + } + + // Handle auto-closing: if the new element auto-closes an open one, + // pop the stack accordingly. + self.handle_auto_close(&lower_tag); + + // For non-structural elements, ensure proper containment + if !self.options.no_implied { + if lower_tag == "frameset" { + // <frameset> replaces <body> in HTML 4.01 — place it + // directly under <html>, not inside <body> + self.close_head_if_open(); + self.ensure_html(); + } else if is_head_content_element(&lower_tag) && !self.is_in_body() { + // Head-content elements go under <head> + let head_id = self.ensure_head(); + if !self.open_elements.iter().any(|(_, t)| t == "head") { + self.open_elements.push((head_id, "head".to_string())); + } + } else if self.is_in_frameset() { + // Inside a frameset context, don't auto-create body. + // Elements like <frame>, <noframes> stay inside frameset. + } else { + // Body-content elements: close head, ensure body exists + self.close_head_if_open(); + self.ensure_body(); + } + } + + let parent = self.current_parent(); + + let id_value = attributes.iter().find_map(|a| { + if a.name == "id" { + Some(a.value.clone()) + } else { + None + } + }); + + let elem_id = self.doc.create_node(NodeKind::Element { + name: lower_tag.clone(), + prefix: None, + namespace: None, + attributes, + }); + self.doc.append_child(parent, elem_id); + + if let Some(id_val) = id_value { + self.doc.set_id(&id_val, elem_id); + } + + // Void elements and explicit self-close don't get pushed + if is_void_element(&lower_tag) || explicit_self_close { + self.input.decrement_depth(); + return; + } + + // Raw text elements (script, style) need special handling + if is_raw_text_element(&lower_tag) { + self.open_elements.push((elem_id, lower_tag.clone())); + self.parse_raw_text(&lower_tag); + // pop the element after raw text + self.open_elements.pop(); + self.input.decrement_depth(); + return; + } + + // HTML4/libxml treats `<plaintext>` as an irreversible switch: every + // remaining byte, including apparent end tags and entity references, + // belongs to one text node. The serializer escapes that text and adds + // the element's synthetic closing tag. + if lower_tag == "plaintext" { + self.open_elements.push((elem_id, lower_tag)); + let remaining = self.input.remaining(); + if !remaining.is_empty() { + let content = String::from_utf8_lossy(remaining).into_owned(); + let text_id = self.doc.create_node(NodeKind::Text { content }); + self.doc.append_child(elem_id, text_id); + self.input.advance(remaining.len()); + } + self.input.decrement_depth(); + return; + } + + self.open_elements.push((elem_id, lower_tag)); + } + + /// Merges attributes from a parsed tag into an existing element node. + fn merge_attributes(&mut self, elem_id: NodeId, attrs: Vec<Attribute>) { + if attrs.is_empty() { + return; + } + if let NodeKind::Element { attributes, .. } = &mut self.doc.node_mut(elem_id).kind { + for attr in attrs { + if !attributes.iter().any(|a| a.name == attr.name) { + attributes.push(attr); + } + } + } + } + + /// Closes `<head>` if it's currently open on the element stack. + fn close_head_if_open(&mut self) { + if self.open_elements.last().is_some_and(|(_, t)| t == "head") { + self.open_elements.pop(); + } + } + + /// Returns true if the parser is currently inside `<body>`. + fn is_in_body(&self) -> bool { + self.open_elements.iter().any(|(_, t)| t == "body") + } + + /// Returns true if the parser is currently inside a `<frameset>`. + fn is_in_frameset(&self) -> bool { + self.open_elements.iter().any(|(_, t)| t == "frameset") + } + + /// Handles auto-closing of open elements when a new element is encountered. + fn handle_auto_close(&mut self, new_tag: &str) { + // Walk the open elements stack from top to find elements that should + // be auto-closed by the new tag. + loop { + let should_close = self + .open_elements + .last() + .is_some_and(|(_, open_tag)| auto_closes(open_tag, new_tag)); + if should_close { + self.open_elements.pop(); + self.input.decrement_depth(); + } else { + break; + } + } + } + + // --- End Tag --- + + fn parse_end_tag(&mut self) { + self.input.advance(2); // consume '</' + let tag = self.parse_tag_name(); + let lower_tag = tag.to_ascii_lowercase(); + + // Skip to '>' + self.input.skip_whitespace(); + if self.input.peek() == Some(b'>') { + self.input.advance(1); + } else if !self.input.at_end() { + self.push_warning(format!("expected '>' after end tag </{lower_tag}>")); + self.skip_to_gt(); + } + + // Void elements should not have end tags; ignore them. + if is_void_element(&lower_tag) { + self.push_warning(format!("end tag for void element </{lower_tag}> ignored")); + return; + } + + // Find the matching open element in the stack + let found = self + .open_elements + .iter() + .rposition(|(_, name)| *name == lower_tag); + + if let Some(idx) = found { + // libxml's HTML4 recovery refuses an ancestor end tag when a + // still-open structural element above it cannot be implicitly + // closed. The unmatched ancestor then remains open to EOF. + let incompatible = |tag: &str| match lower_tag.as_str() { + "table" => false, + "thead" => matches!(tag, "table" | "tbody" | "tfoot"), + "tbody" => matches!(tag, "table" | "tfoot"), + "tfoot" => matches!(tag, "table" | "tbody"), + "tr" => matches!(tag, "table" | "thead" | "tbody" | "tfoot"), + "td" | "th" => matches!( + tag, + "table" | "thead" | "tbody" | "tfoot" | "tr" | "td" | "th" + ), + _ => matches!( + tag, + "div" | "table" | "thead" | "tbody" | "tfoot" | "tr" | "td" | "th" + ), + }; + if self.open_elements[idx + 1..] + .iter() + .any(|(_, tag)| incompatible(tag)) + { + self.push_warning(format!("stray end tag </{lower_tag}>")); + return; + } + // Pop everything above and including the matched element + // (auto-closing any intervening elements) + let count = self.open_elements.len() - idx; + for i in (0..count).rev() { + let stack_idx = idx + i; + if stack_idx < self.open_elements.len() { + let (_, ref closed_tag) = self.open_elements[stack_idx]; + if *closed_tag != lower_tag { + self.push_warning(format!( + "implicitly closing <{closed_tag}> before </{lower_tag}>" + )); + } + } + } + // Decrement depth for each element being closed + for _ in 0..count { + self.input.decrement_depth(); + } + self.open_elements.truncate(idx); + } else { + // No matching open element — stray end tag + self.push_warning(format!("stray end tag </{lower_tag}>")); + } + } + + // --- Attributes --- + + fn parse_attributes(&mut self) -> Vec<Attribute> { + let mut attributes = Vec::new(); + + loop { + self.input.skip_whitespace(); + + // Check for end of tag + if self.input.at_end() + || self.input.peek() == Some(b'>') + || self.input.peek() == Some(b'/') + || self.input.looking_at(b"/>") + { + break; + } + + // Parse attribute name + let name = self.parse_attr_name(); + if name.is_empty() { + // Skip the bad character and continue + self.input.advance(1); + continue; + } + + let lower_name = name.to_ascii_lowercase(); + + self.input.skip_whitespace(); + + // Check for = (attribute with value) + let (value, minimized) = if self.input.peek() == Some(b'=') { + self.input.advance(1); // consume '=' + self.input.skip_whitespace(); + (self.parse_attr_value(), false) + } else { + // libxml exposes HTML4 boolean attributes as their own name; + // other minimized attributes have an empty DOM value. + ( + if is_boolean_attribute(&lower_name) { + lower_name.clone() + } else { + String::new() + }, + true, + ) + }; + + // libxml keeps the first duplicate attribute and discards later + // occurrences without disturbing source order. + if !attributes + .iter() + .any(|attr: &Attribute| attr.name == lower_name) + { + attributes.push(Attribute { + name: lower_name, + value, + prefix: None, + namespace: None, + // An empty raw value cannot be produced by entity + // expansion, so it safely remembers source minimization + // for the libxml-compatible HTML serializer. + raw_value: minimized.then(String::new), + }); + } + } + + attributes + } + + /// Parses an attribute name (sequence of non-whitespace, non-special chars). + fn parse_attr_name(&mut self) -> String { + let start = self.input.pos(); + while let Some(b) = self.input.peek() { + if b == b' ' + || b == b'\t' + || b == b'\r' + || b == b'\n' + || b == b'=' + || b == b'>' + || b == b'/' + || b == b'<' + || b == b'"' + || b == b'\'' + { + break; + } + self.input.advance(1); + } + String::from_utf8_lossy(self.input.slice(start, self.input.pos())).to_string() + } + + /// Parses an attribute value. Handles quoted and unquoted values. + fn parse_attr_value(&mut self) -> String { + if self.input.at_end() { + return String::new(); + } + + let b = self.input.peek(); + if b == Some(b'"') || b == Some(b'\'') { + // Quoted attribute value — we know `b` is Some from the check above + let quote = b.unwrap_or(b'"'); + self.input.advance(1); // consume opening quote + let mut value = String::new(); + while !self.input.at_end() { + let ch = self.input.peek(); + if ch == Some(quote) { + self.input.advance(1); + break; + } + if ch == Some(b'&') { + let resolved = self.parse_html_reference(); + value.push_str(&resolved); + } else { + let c = self.next_char_html(); + value.push(c); + } + } + value + } else { + // Unquoted attribute value + let mut value = String::new(); + while let Some(b) = self.input.peek() { + if b == b' ' + || b == b'\t' + || b == b'\r' + || b == b'\n' + || b == b'>' + || b == b'<' + || b == b'`' + { + break; + } + // Backslash escaping: \c includes both chars literally + // (libxml2 treats \" as literal backslash+quote) + if b == b'\\' { + let c1 = self.next_char_html(); + value.push(c1); + if !self.input.at_end() { + let c2 = self.next_char_html(); + value.push(c2); + } + continue; + } + if b == b'"' || b == b'\'' { + break; + } + if b == b'&' { + let resolved = self.parse_html_reference(); + value.push_str(&resolved); + } else { + let c = self.next_char_html(); + value.push(c); + } + } + value + } + } + + // --- Text Content --- + + fn parse_text(&mut self) { + let mut text = String::new(); + + while !self.input.at_end() { + if self.input.peek() == Some(b'<') { + break; + } + + if self.input.peek() == Some(b'&') { + let resolved = self.parse_html_reference(); + text.push_str(&resolved); + } else { + let ch = self.next_char_html(); + text.push(ch); + } + } + + if !text.is_empty() { + // Strip blank text nodes if configured + if self.options.no_blanks && text.chars().all(char::is_whitespace) { + return; + } + // Ensure body exists for text content (unless whitespace between + // structural elements or we're already inside an element) + if !self.options.no_implied && self.open_elements.is_empty() { + // Whitespace-only text before any elements is ignored + if text.chars().all(char::is_whitespace) { + return; + } + self.ensure_body(); + } + let parent = self.current_parent(); + let text_id = self.doc.create_node(NodeKind::Text { content: text }); + self.doc.append_child(parent, text_id); + } + } + + // --- Raw text (script/style) --- + + fn parse_raw_text(&mut self, tag: &str) { + let mut content = String::new(); + let end_tag_bytes: Vec<u8> = format!("</{tag}").bytes().collect(); + + while !self.input.at_end() { + if self.input.looking_at_ci(&end_tag_bytes) { + break; + } + let ch = self.next_char_html(); + content.push(ch); + } + + if !content.is_empty() { + let parent = self.current_parent(); + let text_id = self.doc.create_node(NodeKind::Text { content }); + self.doc.append_child(parent, text_id); + } + + // Consume the end tag + if !self.input.at_end() { + self.input.advance(end_tag_bytes.len()); + self.input.skip_whitespace(); + if self.input.peek() == Some(b'>') { + self.input.advance(1); + } + } + } + + // --- Comments --- + + fn parse_comment(&mut self) { + self.input.advance(4); // consume '<!--' + + // Handle abrupt closing: <!--> and <!---> + if self.input.peek() == Some(b'>') { + self.input.advance(1); + let parent = self.current_parent(); + let comment_id = self.doc.create_node(NodeKind::Comment { + content: String::new(), + }); + self.doc.append_child(parent, comment_id); + return; + } + if self.input.looking_at(b"->") { + self.input.advance(2); + let parent = self.current_parent(); + let comment_id = self.doc.create_node(NodeKind::Comment { + content: String::new(), + }); + self.doc.append_child(parent, comment_id); + return; + } + + let mut content = String::new(); + let mut terminated = false; + + loop { + if self.input.at_end() { + self.push_warning("unterminated comment".to_string()); + break; + } + // Standard comment end: --> + if self.input.looking_at(b"-->") { + self.input.advance(3); + terminated = true; + break; + } + // Quirky comment end: --!> (libxml2 treats this as -->) + if self.input.looking_at(b"--!>") { + self.input.advance(4); + terminated = true; + break; + } + let ch = self.next_char_html(); + content.push(ch); + } + + // libxml2 drops unterminated comments (those that reach EOF) + if !terminated { + return; + } + + let parent = self.current_parent(); + let comment_id = self.doc.create_node(NodeKind::Comment { content }); + self.doc.append_child(parent, comment_id); + } + + // --- Processing Instructions --- + + fn parse_processing_instruction(&mut self) { + self.input.advance(2); // consume '<?' + let target = self.parse_tag_name(); + self.input.skip_whitespace(); + + let mut data = String::new(); + // In HTML mode, libxml2 reads PI content up to '>' only. + // The '?' before '>' (if present) is included in the content, + // so <?pi data?> stores data as "data?" and serializes as "<?pi data?>". + loop { + if self.input.at_end() { + self.push_warning("unterminated processing instruction".to_string()); + break; + } + if self.input.peek() == Some(b'>') { + self.input.advance(1); + break; + } + let ch = self.next_char_html(); + data.push(ch); + } + + let pi_data = if data.is_empty() { None } else { Some(data) }; + + let parent = self.current_parent(); + let pi_id = self.doc.create_node(NodeKind::ProcessingInstruction { + target, + data: pi_data, + }); + self.doc.append_child(parent, pi_id); + } + + // --- HTML Entity/Character References --- + + /// Parses an HTML character or entity reference, or returns a bare `&` + /// if it doesn't look like a valid reference (error-tolerant). + fn parse_html_reference(&mut self) -> String { + // Save position for backtracking + let saved = self.input.save_position(); + + self.input.advance(1); // consume '&' + + if self.input.peek() == Some(b'#') { + // Character reference + self.input.advance(1); + if self.input.peek() == Some(b'x') || self.input.peek() == Some(b'X') { + // Hex + self.input.advance(1); + let hex = self.take_while_ascii(|b| b.is_ascii_hexdigit()); + if !hex.is_empty() { + if self.input.peek() == Some(b';') { + self.input.advance(1); + } + if let Ok(value) = u32::from_str_radix(&hex, 16) { + return html_char_reference(value).to_string(); + } + } + // Invalid — backtrack + self.input.restore_position(saved); + self.input.advance(1); + return "&".to_string(); + } + // Decimal + let dec = self.take_while_ascii(|b| b.is_ascii_digit()); + if !dec.is_empty() { + if self.input.peek() == Some(b';') { + self.input.advance(1); + } + if let Ok(value) = dec.parse::<u32>() { + return html_char_reference(value).to_string(); + } + } + // Invalid — backtrack + self.input.restore_position(saved); + self.input.advance(1); + return "&".to_string(); + } + + // Named entity reference + let name = self.take_while_ascii(|b| b.is_ascii_alphanumeric()); + if !name.is_empty() { + // Try with semicolon + if self.input.peek() == Some(b';') { + self.input.advance(1); + if let Some(value) = entities::lookup_entity(&name) { + return value.to_string(); + } + // libxml's HTML parser accepts the longest known entity + // prefix even when the complete name is unknown. + for split in (1..name.len()).rev() { + if !name.is_char_boundary(split) { + continue; + } + if let Some(value) = entities::lookup_entity(&name[..split]) { + return format!("{value}{};", &name[split..]); + } + } + // Unknown entity — return it as-is with the & and ; + self.push_warning(format!("unknown entity reference: &{name};")); + return format!("&{name};"); + } + // Try without semicolon (error-tolerant: some HTML uses &amp without ;) + if let Some(value) = entities::lookup_entity(&name) { + self.push_warning(format!("entity reference &{name} missing semicolon")); + return value.to_string(); + } + } + + // Not a valid reference — backtrack and return bare & + self.input.restore_position(saved); + self.input.advance(1); + "&".to_string() + } + + // --- Tag Name Parsing --- + + /// Parses an HTML tag name. Tag names can contain letters, digits, hyphens, + /// and a few other characters. Returns the name as-is (caller normalizes case). + fn parse_tag_name(&mut self) -> String { + let start = self.input.pos(); + while let Some(b) = self.input.peek() { + if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b':' || b == b'.' { + self.input.advance(1); + } else { + break; + } + } + String::from_utf8_lossy(self.input.slice(start, self.input.pos())).to_string() + } + + /// Tries to parse a quoted value for DOCTYPE SYSTEM/PUBLIC identifiers. + fn try_parse_quoted_value(&mut self) -> Option<String> { + let quote = self.input.peek()?; + if quote != b'"' && quote != b'\'' { + return None; + } + self.input.advance(1); + let start = self.input.pos(); + while !self.input.at_end() && self.input.peek() != Some(quote) { + self.input.advance(1); + } + let value = String::from_utf8_lossy(self.input.slice(start, self.input.pos())).to_string(); + if !self.input.at_end() { + self.input.advance(1); // closing quote + } + Some(value) + } + + // --- HTML-specific low-level helpers --- + // These methods are NOT shared with the XML parser because HTML has + // different rules (error tolerance, different character handling, etc.). + + /// Reads the next character, handling CR/LF normalization. + /// Returns `'\0'` at end of input (error-tolerant, unlike the XML parser + /// which returns an error). + fn next_char_html(&mut self) -> char { + if self.input.at_end() { + return '\0'; + } + if let Some(ch) = self.input.peek_char() { + self.input.advance_char(ch); + // Handle \r\n normalization + if ch == '\r' { + if self.input.peek() == Some(b'\n') { + self.input.advance(1); + } + return '\n'; + } + ch + } else { + // Invalid UTF-8 — skip the byte and return replacement character + self.input.advance(1); + '\u{FFFD}' + } + } + + /// Skips an IE conditional comment marker (`<![if ...]>` or `<![endif]>`). + /// + /// These are Microsoft extensions to HTML. Each `<![...>` marker is + /// individually consumed, but the content between them flows as normal + /// text, matching libxml2 behavior. + fn skip_conditional_comment(&mut self) { + self.push_warning("incorrectly opened comment".to_string()); + self.skip_to_gt(); + } + + /// Skips forward to and past the next `>` character. + fn skip_to_gt(&mut self) { + while !self.input.at_end() { + if self.input.peek() == Some(b'>') { + self.input.advance(1); + return; + } + self.input.advance(1); + } + } + + /// Takes characters while they match the predicate (ASCII only). + fn take_while_ascii(&mut self, pred: impl Fn(u8) -> bool) -> String { + let start = self.input.pos(); + while let Some(b) = self.input.peek() { + if pred(b) { + self.input.advance(1); + } else { + break; + } + } + String::from_utf8_lossy(self.input.slice(start, self.input.pos())).to_string() + } + + /// Pushes a warning diagnostic to the document. Respects the + /// `no_warnings` option. + fn push_warning(&mut self, message: String) { + if self.options.no_warnings { + return; + } + self.doc.diagnostics.push(ParseDiagnostic { + severity: ErrorSeverity::Warning, + message, + location: self.input.location(), + }); + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn parse(input: &str) -> Document { + parse_html(input).unwrap_or_else(|e| panic!("parse failed: {e}")) + } + + fn parse_no_implied(input: &str) -> Document { + let opts = HtmlParseOptions::default().no_implied(true); + parse_html_with_options(input, &opts).unwrap_or_else(|e| panic!("parse failed: {e}")) + } + + // --- Basic parsing --- + + #[test] + fn test_parse_simple_html() { + let doc = parse("<html><body><p>Hello</p></body></html>"); + let html = doc.root_element().unwrap(); + assert_eq!(doc.node_name(html), Some("html")); + } + + #[test] + fn test_parse_implied_structure() { + // Even without explicit html/body, the parser adds them + let doc = parse("<p>Hello</p>"); + let html = doc.root_element().unwrap(); + assert_eq!(doc.node_name(html), Some("html")); + + // Should have body child (empty head is removed) + let children: Vec<_> = doc.children(html).collect(); + assert!(!children.is_empty()); + // Body should contain the <p> + let body = children.last().unwrap(); + assert_eq!(doc.node_name(*body), Some("body")); + } + + #[test] + fn test_parse_no_implied_option() { + let doc = parse_no_implied("<p>Hello</p>"); + let root = doc.root(); + let first = doc.first_child(root).unwrap(); + // Without implied structure, p should be directly under root + assert_eq!(doc.node_name(first), Some("p")); + assert_eq!(doc.text_content(first), "Hello"); + } + + // --- Void elements --- + + #[test] + fn test_void_elements() { + let doc = parse_no_implied("<p>line1<br>line2</p>"); + let root = doc.root(); + let p = doc.first_child(root).unwrap(); + let children: Vec<_> = doc.children(p).collect(); + assert_eq!(children.len(), 3); // "line1", <br>, "line2" + assert_eq!(doc.node_name(children[1]), Some("br")); + assert!(doc.first_child(children[1]).is_none()); // br has no children + } + + #[test] + fn test_void_element_with_end_tag() { + let doc = parse_no_implied("<p>text<br></br>more</p>"); + let root = doc.root(); + let p = doc.first_child(root).unwrap(); + // The </br> should be ignored + let children: Vec<_> = doc.children(p).collect(); + assert_eq!(children.len(), 3); // "text", <br>, "more" + } + + #[test] + fn test_img_void_element() { + let doc = parse_no_implied("<img src=\"test.jpg\" alt=\"test\">"); + let root = doc.root(); + let img = doc.first_child(root).unwrap(); + assert_eq!(doc.node_name(img), Some("img")); + assert_eq!(doc.attribute(img, "src"), Some("test.jpg")); + assert_eq!(doc.attribute(img, "alt"), Some("test")); + } + + // --- Case-insensitive tags --- + + #[test] + fn test_case_insensitive_tags() { + let doc = parse_no_implied("<DIV><P>Hello</P></DIV>"); + let root = doc.root(); + let div = doc.first_child(root).unwrap(); + assert_eq!(doc.node_name(div), Some("div")); + let p = doc.first_child(div).unwrap(); + assert_eq!(doc.node_name(p), Some("p")); + assert_eq!(doc.text_content(p), "Hello"); + } + + #[test] + fn test_mixed_case_tags() { + let doc = parse_no_implied("<Div><SPAN>Hi</span></div>"); + let root = doc.root(); + let div = doc.first_child(root).unwrap(); + assert_eq!(doc.node_name(div), Some("div")); + } + + // --- Unquoted attributes --- + + #[test] + fn test_unquoted_attributes() { + let doc = parse_no_implied("<div class=main id=content>text</div>"); + let root = doc.root(); + let div = doc.first_child(root).unwrap(); + assert_eq!(doc.attribute(div, "class"), Some("main")); + assert_eq!(doc.attribute(div, "id"), Some("content")); + } + + #[test] + fn test_boolean_attributes() { + let doc = parse_no_implied("<input disabled readonly>"); + let root = doc.root(); + let input = doc.first_child(root).unwrap(); + assert_eq!(doc.attribute(input, "disabled"), Some("disabled")); + assert_eq!(doc.attribute(input, "readonly"), Some("readonly")); + } + + // --- Auto-closing --- + + #[test] + fn test_p_auto_closes_p() { + let doc = parse_no_implied("<p>First<p>Second"); + let root = doc.root(); + let children: Vec<_> = doc.children(root).collect(); + // Should have two p elements as siblings, not nested + assert_eq!(children.len(), 2); + assert_eq!(doc.node_name(children[0]), Some("p")); + assert_eq!(doc.text_content(children[0]), "First"); + assert_eq!(doc.node_name(children[1]), Some("p")); + assert_eq!(doc.text_content(children[1]), "Second"); + } + + #[test] + fn test_li_auto_closes_li() { + let doc = parse_no_implied("<ul><li>A<li>B<li>C</ul>"); + let root = doc.root(); + let ul = doc.first_child(root).unwrap(); + let items: Vec<_> = doc.children(ul).collect(); + assert_eq!(items.len(), 3); + assert_eq!(doc.text_content(items[0]), "A"); + assert_eq!(doc.text_content(items[1]), "B"); + assert_eq!(doc.text_content(items[2]), "C"); + } + + #[test] + fn test_dd_dt_auto_close() { + let doc = parse_no_implied("<dl><dt>Term<dd>Def<dt>Term2<dd>Def2</dl>"); + let root = doc.root(); + let dl = doc.first_child(root).unwrap(); + let items: Vec<_> = doc.children(dl).collect(); + assert_eq!(items.len(), 4); + assert_eq!(doc.node_name(items[0]), Some("dt")); + assert_eq!(doc.node_name(items[1]), Some("dd")); + assert_eq!(doc.node_name(items[2]), Some("dt")); + assert_eq!(doc.node_name(items[3]), Some("dd")); + } + + // --- Entity references --- + + #[test] + fn test_html_entities() { + let doc = parse_no_implied("<p>&copy; 2024 &mdash; All rights reserved</p>"); + let root = doc.root(); + let p = doc.first_child(root).unwrap(); + let text = doc.text_content(p); + assert!(text.contains('\u{00A9}')); // copyright sign + assert!(text.contains('\u{2014}')); // em dash + } + + #[test] + fn test_bare_ampersand() { + let doc = parse_no_implied("<p>A & B</p>"); + let root = doc.root(); + let p = doc.first_child(root).unwrap(); + assert_eq!(doc.text_content(p), "A & B"); + } + + #[test] + fn test_numeric_character_reference() { + let doc = parse_no_implied("<p>&#65; &#x42;</p>"); + let root = doc.root(); + let p = doc.first_child(root).unwrap(); + assert_eq!(doc.text_content(p), "A B"); + } + + #[test] + fn test_libxml_numeric_reference_recovery() { + let doc = parse_no_implied("<p>&#x80; &#128 &#0 &#x110000</p>"); + let p = doc.first_child(doc.root()).unwrap(); + assert_eq!(doc.text_content(p), "€ € � �"); + } + + #[test] + fn test_minimized_attribute_values() { + let doc = parse_no_implied("<div foo checked hidden=hidden></div>"); + let div = doc.first_child(doc.root()).unwrap(); + assert_eq!(doc.attribute(div, "foo"), Some("")); + assert_eq!(doc.attribute(div, "checked"), Some("checked")); + assert_eq!(doc.attribute(div, "hidden"), Some("hidden")); + } + + // --- Missing closing tags --- + + #[test] + fn test_missing_closing_tags() { + let doc = parse_no_implied("<div><p>Hello"); + let root = doc.root(); + let div = doc.first_child(root).unwrap(); + assert_eq!(doc.node_name(div), Some("div")); + let p = doc.first_child(div).unwrap(); + assert_eq!(doc.node_name(p), Some("p")); + assert_eq!(doc.text_content(p), "Hello"); + } + + // --- Comments --- + + #[test] + fn test_html_comment() { + let doc = parse_no_implied("<!-- hello --><p>text</p>"); + let root = doc.root(); + let first = doc.first_child(root).unwrap(); + assert_eq!(doc.node_text(first), Some(" hello ")); + } + + // --- Script/style raw text --- + + #[test] + fn test_script_raw_text() { + let doc = parse_no_implied("<script>var x = 1 < 2 && true;</script>"); + let root = doc.root(); + let script = doc.first_child(root).unwrap(); + assert_eq!(doc.node_name(script), Some("script")); + assert_eq!(doc.text_content(script), "var x = 1 < 2 && true;"); + } + + #[test] + fn test_style_raw_text() { + let doc = parse_no_implied("<style>p > span { color: red; }</style>"); + let root = doc.root(); + let style = doc.first_child(root).unwrap(); + assert_eq!(doc.node_name(style), Some("style")); + assert_eq!(doc.text_content(style), "p > span { color: red; }"); + } + + // --- DOCTYPE --- + + #[test] + fn test_html_doctype() { + let doc = parse_no_implied("<!DOCTYPE html><p>text</p>"); + let root = doc.root(); + let children: Vec<_> = doc.children(root).collect(); + assert!(children.len() >= 2); + match &doc.node(children[0]).kind { + NodeKind::DocumentType { name, .. } => { + assert_eq!(name, "html"); + } + other => panic!("expected DocumentType, got {other:?}"), + } + } + + // --- Single-quoted and double-quoted attributes --- + + #[test] + fn test_single_quoted_attributes() { + let doc = parse_no_implied("<div class='main'>text</div>"); + let root = doc.root(); + let div = doc.first_child(root).unwrap(); + assert_eq!(doc.attribute(div, "class"), Some("main")); + } + + // --- Processing instructions --- + + #[test] + fn test_html_processing_instruction() { + let doc = parse_no_implied("<?xml-stylesheet type=\"text/css\"?><p>text</p>"); + let root = doc.root(); + let first = doc.first_child(root).unwrap(); + assert_eq!(doc.node_name(first), Some("xml-stylesheet")); + } + + // --- Attribute case normalization --- + + #[test] + fn test_attribute_case_normalized() { + let doc = parse_no_implied("<div CLASS=\"main\" ID=\"1\">text</div>"); + let root = doc.root(); + let div = doc.first_child(root).unwrap(); + assert_eq!(doc.attribute(div, "class"), Some("main")); + assert_eq!(doc.attribute(div, "id"), Some("1")); + } + + // --- Whitespace stripping --- + + #[test] + fn test_no_blanks_option() { + let opts = HtmlParseOptions::default().no_blanks(true).no_implied(true); + let doc = parse_html_with_options("<div> \n <p>text</p> \n </div>", &opts).unwrap(); + let root = doc.root(); + let div = doc.first_child(root).unwrap(); + // With no_blanks, whitespace-only text nodes are stripped + let children: Vec<_> = doc.children(div).collect(); + assert_eq!(children.len(), 1); + assert_eq!(doc.node_name(children[0]), Some("p")); + } + + // --- Stray end tags --- + + #[test] + fn test_stray_end_tag() { + let doc = parse_no_implied("</div><p>text</p>"); + let root = doc.root(); + let p = doc.first_child(root).unwrap(); + assert_eq!(doc.node_name(p), Some("p")); + assert_eq!(doc.text_content(p), "text"); + // Should have a diagnostic about the stray end tag + assert!(!doc.diagnostics.is_empty()); + } + + // --- Complex document --- + + #[test] + fn test_complex_html_document() { + let doc = parse( + r#"<!DOCTYPE html> +<html> +<head><title>Test</title></head> +<body> +<h1>Hello</h1> +<p>A paragraph with <b>bold</b> and <em>emphasis</em>.</p> +<ul> +<li>Item 1 +<li>Item 2 +<li>Item 3 +</ul> +<img src="test.jpg"> +</body> +</html>"#, + ); + let html = doc.root_element().unwrap(); + assert_eq!(doc.node_name(html), Some("html")); + } + + // --- Self-closing syntax --- + + #[test] + fn test_self_closing_syntax() { + let doc = parse_no_implied("<br/>"); + let root = doc.root(); + let br = doc.first_child(root).unwrap(); + assert_eq!(doc.node_name(br), Some("br")); + assert!(doc.first_child(br).is_none()); + } + + // --- Entity in attribute --- + + #[test] + fn test_entity_in_attribute() { + let doc = parse_no_implied("<a href=\"page?a=1&amp;b=2\">link</a>"); + let root = doc.root(); + let a = doc.first_child(root).unwrap(); + assert_eq!(doc.attribute(a, "href"), Some("page?a=1&b=2")); + } +} diff --git a/browser/vendor/xmloxide/src/html5/entities.rs b/browser/vendor/xmloxide/src/html5/entities.rs new file mode 100644 index 000000000..dca7ae738 --- /dev/null +++ b/browser/vendor/xmloxide/src/html5/entities.rs @@ -0,0 +1,2318 @@ +//! HTML5 named character references. +//! +//! This module provides a lookup table for the full set of HTML5 named +//! character references as defined by the WHATWG HTML Living Standard. +//! This extends far beyond the HTML 4.01 entity set (252 entities) to +//! cover 2,125 named references including mathematical operators, arrows, +//! letterlike symbols, and combining characters. +//! +//! Some entities expand to multiple Unicode code points (e.g., `NotEqualTilde` +//! maps to U+2242 U+0338). These are represented as multi-character `&str` values. +//! +//! See <https://html.spec.whatwg.org/multipage/named-characters.html> + +/// Looks up an HTML5 named character reference and returns the corresponding +/// Unicode character(s) as a string slice. +/// +/// Returns `None` if the name is not a recognized HTML5 entity. The entity name +/// should be provided without the leading `&` and trailing `;`. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::html5::entities::lookup_entity; +/// +/// assert_eq!(lookup_entity("nbsp"), Some("\u{00A0}")); +/// assert_eq!(lookup_entity("copy"), Some("\u{00A9}")); +/// assert_eq!(lookup_entity("NotEqualTilde"), Some("\u{2242}\u{0338}")); +/// assert_eq!(lookup_entity("nonexistent"), None); +/// ``` +pub fn lookup_entity(name: &str) -> Option<&'static str> { + // Binary search on the sorted entity table. + ENTITIES + .binary_search_by_key(&name, |&(n, _)| n) + .ok() + .map(|i| ENTITIES[i].1) +} + +/// Looks up the HTML5 named entity for a given character (reverse lookup). +/// +/// Returns `None` if no named entity exists for the character, or if the +/// character is one of the XML builtins (`&`, `<`, `>`, `'`, `"`) which +/// are handled separately by the escaping logic. +/// +/// Note that this only matches single-character entities. Entities that expand +/// to multiple code points cannot be reverse-looked-up by a single `char`. +/// When multiple entity names map to the same character, the first name in +/// alphabetical order is returned. +/// +/// Used by the HTML serializer to re-encode non-ASCII characters as their +/// named entity form (e.g., \u{00A9} to `&copy;`, \u{00A0} to `&nbsp;`). +/// +/// # Examples +/// +/// ``` +/// use xmloxide::html5::entities::reverse_lookup_entity; +/// +/// assert_eq!(reverse_lookup_entity('\u{0161}'), Some("scaron")); +/// assert_eq!(reverse_lookup_entity('\u{20AC}'), Some("euro")); +/// assert_eq!(reverse_lookup_entity('A'), None); +/// ``` +pub fn reverse_lookup_entity(ch: char) -> Option<&'static str> { + let mut buf = [0u8; 4]; + let target = ch.encode_utf8(&mut buf); + for &(name, value) in ENTITIES { + if value == target { + return Some(name); + } + } + None +} + +/// Returns `true` if the given entity name is a legacy named character reference +/// that is valid without a trailing semicolon. +/// +/// Per the WHATWG spec, these are the 106 named character references from +/// HTML 4.01 that are recognized even without a terminating `;`. +pub fn is_legacy_named_entity(name: &str) -> bool { + LEGACY_ENTITIES.binary_search(&name).is_ok() +} + +/// The 106 legacy named character references that are valid without a +/// trailing semicolon, sorted for binary search. +static LEGACY_ENTITIES: &[&str] = &[ + "AElig", "AMP", "Aacute", "Acirc", "Agrave", "Aring", "Atilde", "Auml", "COPY", "Ccedil", + "ETH", "Eacute", "Ecirc", "Egrave", "Euml", "GT", "Iacute", "Icirc", "Igrave", "Iuml", "LT", + "Ntilde", "Oacute", "Ocirc", "Ograve", "Oslash", "Otilde", "Ouml", "QUOT", "REG", "THORN", + "Uacute", "Ucirc", "Ugrave", "Uuml", "Yacute", "aacute", "acirc", "acute", "aelig", "agrave", + "amp", "aring", "atilde", "auml", "brvbar", "ccedil", "cedil", "cent", "copy", "curren", "deg", + "divide", "eacute", "ecirc", "egrave", "eth", "euml", "frac12", "frac14", "frac34", "gt", + "iacute", "icirc", "iexcl", "igrave", "iquest", "iuml", "laquo", "lt", "macr", "micro", + "middot", "nbsp", "not", "ntilde", "oacute", "ocirc", "ograve", "ordf", "ordm", "oslash", + "otilde", "ouml", "para", "plusmn", "pound", "quot", "raquo", "reg", "sect", "shy", "sup1", + "sup2", "sup3", "szlig", "thorn", "times", "uacute", "ucirc", "ugrave", "uml", "uuml", + "yacute", "yen", "yuml", +]; + +/// The HTML5 named character reference table, sorted by name for binary +/// search. Each entry is `(entity_name, replacement_str)`. +/// +/// This covers all 2125 named character references defined in the +/// WHATWG HTML Living Standard, including single- and multi-codepoint entities. +static ENTITIES: &[(&str, &str)] = &[ + ("AElig", "\u{00C6}"), + ("AMP", "&"), + ("Aacute", "\u{00C1}"), + ("Abreve", "\u{0102}"), + ("Acirc", "\u{00C2}"), + ("Acy", "\u{0410}"), + ("Afr", "\u{1D504}"), + ("Agrave", "\u{00C0}"), + ("Alpha", "\u{0391}"), + ("Amacr", "\u{0100}"), + ("And", "\u{2A53}"), + ("Aogon", "\u{0104}"), + ("Aopf", "\u{1D538}"), + ("ApplyFunction", "\u{2061}"), + ("Aring", "\u{00C5}"), + ("Ascr", "\u{1D49C}"), + ("Assign", "\u{2254}"), + ("Atilde", "\u{00C3}"), + ("Auml", "\u{00C4}"), + ("Backslash", "\u{2216}"), + ("Barv", "\u{2AE7}"), + ("Barwed", "\u{2306}"), + ("Bcy", "\u{0411}"), + ("Because", "\u{2235}"), + ("Bernoullis", "\u{212C}"), + ("Beta", "\u{0392}"), + ("Bfr", "\u{1D505}"), + ("Bopf", "\u{1D539}"), + ("Breve", "\u{02D8}"), + ("Bscr", "\u{212C}"), + ("Bumpeq", "\u{224E}"), + ("CHcy", "\u{0427}"), + ("COPY", "\u{00A9}"), + ("Cacute", "\u{0106}"), + ("Cap", "\u{22D2}"), + ("CapitalDifferentialD", "\u{2145}"), + ("Cayleys", "\u{212D}"), + ("Ccaron", "\u{010C}"), + ("Ccedil", "\u{00C7}"), + ("Ccirc", "\u{0108}"), + ("Cconint", "\u{2230}"), + ("Cdot", "\u{010A}"), + ("Cedilla", "\u{00B8}"), + ("CenterDot", "\u{00B7}"), + ("Cfr", "\u{212D}"), + ("Chi", "\u{03A7}"), + ("CircleDot", "\u{2299}"), + ("CircleMinus", "\u{2296}"), + ("CirclePlus", "\u{2295}"), + ("CircleTimes", "\u{2297}"), + ("ClockwiseContourIntegral", "\u{2232}"), + ("CloseCurlyDoubleQuote", "\u{201D}"), + ("CloseCurlyQuote", "\u{2019}"), + ("Colon", "\u{2237}"), + ("Colone", "\u{2A74}"), + ("Congruent", "\u{2261}"), + ("Conint", "\u{222F}"), + ("ContourIntegral", "\u{222E}"), + ("Copf", "\u{2102}"), + ("Coproduct", "\u{2210}"), + ("CounterClockwiseContourIntegral", "\u{2233}"), + ("Cross", "\u{2A2F}"), + ("Cscr", "\u{1D49E}"), + ("Cup", "\u{22D3}"), + ("CupCap", "\u{224D}"), + ("DD", "\u{2145}"), + ("DDotrahd", "\u{2911}"), + ("DJcy", "\u{0402}"), + ("DScy", "\u{0405}"), + ("DZcy", "\u{040F}"), + ("Dagger", "\u{2021}"), + ("Darr", "\u{21A1}"), + ("Dashv", "\u{2AE4}"), + ("Dcaron", "\u{010E}"), + ("Dcy", "\u{0414}"), + ("Del", "\u{2207}"), + ("Delta", "\u{0394}"), + ("Dfr", "\u{1D507}"), + ("DiacriticalAcute", "\u{00B4}"), + ("DiacriticalDot", "\u{02D9}"), + ("DiacriticalDoubleAcute", "\u{02DD}"), + ("DiacriticalGrave", "\u{0060}"), + ("DiacriticalTilde", "\u{02DC}"), + ("Diamond", "\u{22C4}"), + ("DifferentialD", "\u{2146}"), + ("Dopf", "\u{1D53B}"), + ("Dot", "\u{00A8}"), + ("DotDot", "\u{20DC}"), + ("DotEqual", "\u{2250}"), + ("DoubleContourIntegral", "\u{222F}"), + ("DoubleDot", "\u{00A8}"), + ("DoubleDownArrow", "\u{21D3}"), + ("DoubleLeftArrow", "\u{21D0}"), + ("DoubleLeftRightArrow", "\u{21D4}"), + ("DoubleLeftTee", "\u{2AE4}"), + ("DoubleLongLeftArrow", "\u{27F8}"), + ("DoubleLongLeftRightArrow", "\u{27FA}"), + ("DoubleLongRightArrow", "\u{27F9}"), + ("DoubleRightArrow", "\u{21D2}"), + ("DoubleRightTee", "\u{22A8}"), + ("DoubleUpArrow", "\u{21D1}"), + ("DoubleUpDownArrow", "\u{21D5}"), + ("DoubleVerticalBar", "\u{2225}"), + ("DownArrow", "\u{2193}"), + ("DownArrowBar", "\u{2913}"), + ("DownArrowUpArrow", "\u{21F5}"), + ("DownBreve", "\u{0311}"), + ("DownLeftRightVector", "\u{2950}"), + ("DownLeftTeeVector", "\u{295E}"), + ("DownLeftVector", "\u{21BD}"), + ("DownLeftVectorBar", "\u{2956}"), + ("DownRightTeeVector", "\u{295F}"), + ("DownRightVector", "\u{21C1}"), + ("DownRightVectorBar", "\u{2957}"), + ("DownTee", "\u{22A4}"), + ("DownTeeArrow", "\u{21A7}"), + ("Downarrow", "\u{21D3}"), + ("Dscr", "\u{1D49F}"), + ("Dstrok", "\u{0110}"), + ("ENG", "\u{014A}"), + ("ETH", "\u{00D0}"), + ("Eacute", "\u{00C9}"), + ("Ecaron", "\u{011A}"), + ("Ecirc", "\u{00CA}"), + ("Ecy", "\u{042D}"), + ("Edot", "\u{0116}"), + ("Efr", "\u{1D508}"), + ("Egrave", "\u{00C8}"), + ("Element", "\u{2208}"), + ("Emacr", "\u{0112}"), + ("EmptySmallSquare", "\u{25FB}"), + ("EmptyVerySmallSquare", "\u{25AB}"), + ("Eogon", "\u{0118}"), + ("Eopf", "\u{1D53C}"), + ("Epsilon", "\u{0395}"), + ("Equal", "\u{2A75}"), + ("EqualTilde", "\u{2242}"), + ("Equilibrium", "\u{21CC}"), + ("Escr", "\u{2130}"), + ("Esim", "\u{2A73}"), + ("Eta", "\u{0397}"), + ("Euml", "\u{00CB}"), + ("Exists", "\u{2203}"), + ("ExponentialE", "\u{2147}"), + ("Fcy", "\u{0424}"), + ("Ffr", "\u{1D509}"), + ("FilledSmallSquare", "\u{25FC}"), + ("FilledVerySmallSquare", "\u{25AA}"), + ("Fopf", "\u{1D53D}"), + ("ForAll", "\u{2200}"), + ("Fouriertrf", "\u{2131}"), + ("Fscr", "\u{2131}"), + ("GJcy", "\u{0403}"), + ("GT", ">"), + ("Gamma", "\u{0393}"), + ("Gammad", "\u{03DC}"), + ("Gbreve", "\u{011E}"), + ("Gcedil", "\u{0122}"), + ("Gcirc", "\u{011C}"), + ("Gcy", "\u{0413}"), + ("Gdot", "\u{0120}"), + ("Gfr", "\u{1D50A}"), + ("Gg", "\u{22D9}"), + ("Gopf", "\u{1D53E}"), + ("GreaterEqual", "\u{2265}"), + ("GreaterEqualLess", "\u{22DB}"), + ("GreaterFullEqual", "\u{2267}"), + ("GreaterGreater", "\u{2AA2}"), + ("GreaterLess", "\u{2277}"), + ("GreaterSlantEqual", "\u{2A7E}"), + ("GreaterTilde", "\u{2273}"), + ("Gscr", "\u{1D4A2}"), + ("Gt", "\u{226B}"), + ("HARDcy", "\u{042A}"), + ("Hacek", "\u{02C7}"), + ("Hat", "\u{005E}"), + ("Hcirc", "\u{0124}"), + ("Hfr", "\u{210C}"), + ("HilbertSpace", "\u{210B}"), + ("Hopf", "\u{210D}"), + ("HorizontalLine", "\u{2500}"), + ("Hscr", "\u{210B}"), + ("Hstrok", "\u{0126}"), + ("HumpDownHump", "\u{224E}"), + ("HumpEqual", "\u{224F}"), + ("IEcy", "\u{0415}"), + ("IJlig", "\u{0132}"), + ("IOcy", "\u{0401}"), + ("Iacute", "\u{00CD}"), + ("Icirc", "\u{00CE}"), + ("Icy", "\u{0418}"), + ("Idot", "\u{0130}"), + ("Ifr", "\u{2111}"), + ("Igrave", "\u{00CC}"), + ("Im", "\u{2111}"), + ("Imacr", "\u{012A}"), + ("ImaginaryI", "\u{2148}"), + ("Implies", "\u{21D2}"), + ("Int", "\u{222C}"), + ("Integral", "\u{222B}"), + ("Intersection", "\u{22C2}"), + ("InvisibleComma", "\u{2063}"), + ("InvisibleTimes", "\u{2062}"), + ("Iogon", "\u{012E}"), + ("Iopf", "\u{1D540}"), + ("Iota", "\u{0399}"), + ("Iscr", "\u{2110}"), + ("Itilde", "\u{0128}"), + ("Iukcy", "\u{0406}"), + ("Iuml", "\u{00CF}"), + ("Jcirc", "\u{0134}"), + ("Jcy", "\u{0419}"), + ("Jfr", "\u{1D50D}"), + ("Jopf", "\u{1D541}"), + ("Jscr", "\u{1D4A5}"), + ("Jsercy", "\u{0408}"), + ("Jukcy", "\u{0404}"), + ("KHcy", "\u{0425}"), + ("KJcy", "\u{040C}"), + ("Kappa", "\u{039A}"), + ("Kcedil", "\u{0136}"), + ("Kcy", "\u{041A}"), + ("Kfr", "\u{1D50E}"), + ("Kopf", "\u{1D542}"), + ("Kscr", "\u{1D4A6}"), + ("LJcy", "\u{0409}"), + ("LT", "<"), + ("Lacute", "\u{0139}"), + ("Lambda", "\u{039B}"), + ("Lang", "\u{27EA}"), + ("Laplacetrf", "\u{2112}"), + ("Larr", "\u{219E}"), + ("Lcaron", "\u{013D}"), + ("Lcedil", "\u{013B}"), + ("Lcy", "\u{041B}"), + ("LeftAngleBracket", "\u{27E8}"), + ("LeftArrow", "\u{2190}"), + ("LeftArrowBar", "\u{21E4}"), + ("LeftArrowRightArrow", "\u{21C6}"), + ("LeftCeiling", "\u{2308}"), + ("LeftDoubleBracket", "\u{27E6}"), + ("LeftDownTeeVector", "\u{2961}"), + ("LeftDownVector", "\u{21C3}"), + ("LeftDownVectorBar", "\u{2959}"), + ("LeftFloor", "\u{230A}"), + ("LeftRightArrow", "\u{2194}"), + ("LeftRightVector", "\u{294E}"), + ("LeftTee", "\u{22A3}"), + ("LeftTeeArrow", "\u{21A4}"), + ("LeftTeeVector", "\u{295A}"), + ("LeftTriangle", "\u{22B2}"), + ("LeftTriangleBar", "\u{29CF}"), + ("LeftTriangleEqual", "\u{22B4}"), + ("LeftUpDownVector", "\u{2951}"), + ("LeftUpTeeVector", "\u{2960}"), + ("LeftUpVector", "\u{21BF}"), + ("LeftUpVectorBar", "\u{2958}"), + ("LeftVector", "\u{21BC}"), + ("LeftVectorBar", "\u{2952}"), + ("Leftarrow", "\u{21D0}"), + ("Leftrightarrow", "\u{21D4}"), + ("LessEqualGreater", "\u{22DA}"), + ("LessFullEqual", "\u{2266}"), + ("LessGreater", "\u{2276}"), + ("LessLess", "\u{2AA1}"), + ("LessSlantEqual", "\u{2A7D}"), + ("LessTilde", "\u{2272}"), + ("Lfr", "\u{1D50F}"), + ("Ll", "\u{22D8}"), + ("Lleftarrow", "\u{21DA}"), + ("Lmidot", "\u{013F}"), + ("LongLeftArrow", "\u{27F5}"), + ("LongLeftRightArrow", "\u{27F7}"), + ("LongRightArrow", "\u{27F6}"), + ("Longleftarrow", "\u{27F8}"), + ("Longleftrightarrow", "\u{27FA}"), + ("Longrightarrow", "\u{27F9}"), + ("Lopf", "\u{1D543}"), + ("LowerLeftArrow", "\u{2199}"), + ("LowerRightArrow", "\u{2198}"), + ("Lscr", "\u{2112}"), + ("Lsh", "\u{21B0}"), + ("Lstrok", "\u{0141}"), + ("Lt", "\u{226A}"), + ("Map", "\u{2905}"), + ("Mcy", "\u{041C}"), + ("MediumSpace", "\u{205F}"), + ("Mellintrf", "\u{2133}"), + ("Mfr", "\u{1D510}"), + ("MinusPlus", "\u{2213}"), + ("Mopf", "\u{1D544}"), + ("Mscr", "\u{2133}"), + ("Mu", "\u{039C}"), + ("NJcy", "\u{040A}"), + ("Nacute", "\u{0143}"), + ("Ncaron", "\u{0147}"), + ("Ncedil", "\u{0145}"), + ("Ncy", "\u{041D}"), + ("NegativeMediumSpace", "\u{200B}"), + ("NegativeThickSpace", "\u{200B}"), + ("NegativeThinSpace", "\u{200B}"), + ("NegativeVeryThinSpace", "\u{200B}"), + ("NestedGreaterGreater", "\u{226B}"), + ("NestedLessLess", "\u{226A}"), + ("NewLine", "\n"), + ("Nfr", "\u{1D511}"), + ("NoBreak", "\u{2060}"), + ("NonBreakingSpace", "\u{00A0}"), + ("Nopf", "\u{2115}"), + ("Not", "\u{2AEC}"), + ("NotCongruent", "\u{2262}"), + ("NotCupCap", "\u{226D}"), + ("NotDoubleVerticalBar", "\u{2226}"), + ("NotElement", "\u{2209}"), + ("NotEqual", "\u{2260}"), + ("NotEqualTilde", "\u{2242}\u{0338}"), + ("NotExists", "\u{2204}"), + ("NotGreater", "\u{226F}"), + ("NotGreaterEqual", "\u{2271}"), + ("NotGreaterFullEqual", "\u{2267}\u{0338}"), + ("NotGreaterGreater", "\u{226B}\u{0338}"), + ("NotGreaterLess", "\u{2279}"), + ("NotGreaterSlantEqual", "\u{2A7E}\u{0338}"), + ("NotGreaterTilde", "\u{2275}"), + ("NotHumpDownHump", "\u{224E}\u{0338}"), + ("NotHumpEqual", "\u{224F}\u{0338}"), + ("NotLeftTriangle", "\u{22EA}"), + ("NotLeftTriangleBar", "\u{29CF}\u{0338}"), + ("NotLeftTriangleEqual", "\u{22EC}"), + ("NotLess", "\u{226E}"), + ("NotLessEqual", "\u{2270}"), + ("NotLessGreater", "\u{2278}"), + ("NotLessLess", "\u{226A}\u{0338}"), + ("NotLessSlantEqual", "\u{2A7D}\u{0338}"), + ("NotLessTilde", "\u{2274}"), + ("NotNestedGreaterGreater", "\u{2AA2}\u{0338}"), + ("NotNestedLessLess", "\u{2AA1}\u{0338}"), + ("NotPrecedes", "\u{2280}"), + ("NotPrecedesEqual", "\u{2AAF}\u{0338}"), + ("NotPrecedesSlantEqual", "\u{22E0}"), + ("NotReverseElement", "\u{220C}"), + ("NotRightTriangle", "\u{22EB}"), + ("NotRightTriangleBar", "\u{29D0}\u{0338}"), + ("NotRightTriangleEqual", "\u{22ED}"), + ("NotSquareSubset", "\u{228F}\u{0338}"), + ("NotSquareSubsetEqual", "\u{22E2}"), + ("NotSquareSuperset", "\u{2290}\u{0338}"), + ("NotSquareSupersetEqual", "\u{22E3}"), + ("NotSubset", "\u{2282}\u{20D2}"), + ("NotSubsetEqual", "\u{2288}"), + ("NotSucceeds", "\u{2281}"), + ("NotSucceedsEqual", "\u{2AB0}\u{0338}"), + ("NotSucceedsSlantEqual", "\u{22E1}"), + ("NotSucceedsTilde", "\u{227F}\u{0338}"), + ("NotSuperset", "\u{2283}\u{20D2}"), + ("NotSupersetEqual", "\u{2289}"), + ("NotTilde", "\u{2241}"), + ("NotTildeEqual", "\u{2244}"), + ("NotTildeFullEqual", "\u{2247}"), + ("NotTildeTilde", "\u{2249}"), + ("NotVerticalBar", "\u{2224}"), + ("Nscr", "\u{1D4A9}"), + ("Ntilde", "\u{00D1}"), + ("Nu", "\u{039D}"), + ("OElig", "\u{0152}"), + ("Oacute", "\u{00D3}"), + ("Ocirc", "\u{00D4}"), + ("Ocy", "\u{041E}"), + ("Odblac", "\u{0150}"), + ("Ofr", "\u{1D512}"), + ("Ograve", "\u{00D2}"), + ("Omacr", "\u{014C}"), + ("Omega", "\u{03A9}"), + ("Omicron", "\u{039F}"), + ("Oopf", "\u{1D546}"), + ("OpenCurlyDoubleQuote", "\u{201C}"), + ("OpenCurlyQuote", "\u{2018}"), + ("Or", "\u{2A54}"), + ("Oscr", "\u{1D4AA}"), + ("Oslash", "\u{00D8}"), + ("Otilde", "\u{00D5}"), + ("Otimes", "\u{2A37}"), + ("Ouml", "\u{00D6}"), + ("OverBar", "\u{203E}"), + ("OverBrace", "\u{23DE}"), + ("OverBracket", "\u{23B4}"), + ("OverParenthesis", "\u{23DC}"), + ("PartialD", "\u{2202}"), + ("Pcy", "\u{041F}"), + ("Pfr", "\u{1D513}"), + ("Phi", "\u{03A6}"), + ("Pi", "\u{03A0}"), + ("PlusMinus", "\u{00B1}"), + ("Poincareplane", "\u{210C}"), + ("Popf", "\u{2119}"), + ("Pr", "\u{2ABB}"), + ("Precedes", "\u{227A}"), + ("PrecedesEqual", "\u{2AAF}"), + ("PrecedesSlantEqual", "\u{227C}"), + ("PrecedesTilde", "\u{227E}"), + ("Prime", "\u{2033}"), + ("Product", "\u{220F}"), + ("Proportion", "\u{2237}"), + ("Proportional", "\u{221D}"), + ("Pscr", "\u{1D4AB}"), + ("Psi", "\u{03A8}"), + ("QUOT", "\""), + ("Qfr", "\u{1D514}"), + ("Qopf", "\u{211A}"), + ("Qscr", "\u{1D4AC}"), + ("RBarr", "\u{2910}"), + ("REG", "\u{00AE}"), + ("Racute", "\u{0154}"), + ("Rang", "\u{27EB}"), + ("Rarr", "\u{21A0}"), + ("Rarrtl", "\u{2916}"), + ("Rcaron", "\u{0158}"), + ("Rcedil", "\u{0156}"), + ("Rcy", "\u{0420}"), + ("Re", "\u{211C}"), + ("ReverseElement", "\u{220B}"), + ("ReverseEquilibrium", "\u{21CB}"), + ("ReverseUpEquilibrium", "\u{296F}"), + ("Rfr", "\u{211C}"), + ("Rho", "\u{03A1}"), + ("RightAngleBracket", "\u{27E9}"), + ("RightArrow", "\u{2192}"), + ("RightArrowBar", "\u{21E5}"), + ("RightArrowLeftArrow", "\u{21C4}"), + ("RightCeiling", "\u{2309}"), + ("RightDoubleBracket", "\u{27E7}"), + ("RightDownTeeVector", "\u{295D}"), + ("RightDownVector", "\u{21C2}"), + ("RightDownVectorBar", "\u{2955}"), + ("RightFloor", "\u{230B}"), + ("RightTee", "\u{22A2}"), + ("RightTeeArrow", "\u{21A6}"), + ("RightTeeVector", "\u{295B}"), + ("RightTriangle", "\u{22B3}"), + ("RightTriangleBar", "\u{29D0}"), + ("RightTriangleEqual", "\u{22B5}"), + ("RightUpDownVector", "\u{294F}"), + ("RightUpTeeVector", "\u{295C}"), + ("RightUpVector", "\u{21BE}"), + ("RightUpVectorBar", "\u{2954}"), + ("RightVector", "\u{21C0}"), + ("RightVectorBar", "\u{2953}"), + ("Rightarrow", "\u{21D2}"), + ("Ropf", "\u{211D}"), + ("RoundImplies", "\u{2970}"), + ("Rrightarrow", "\u{21DB}"), + ("Rscr", "\u{211B}"), + ("Rsh", "\u{21B1}"), + ("RuleDelayed", "\u{29F4}"), + ("SHCHcy", "\u{0429}"), + ("SHcy", "\u{0428}"), + ("SOFTcy", "\u{042C}"), + ("Sacute", "\u{015A}"), + ("Sc", "\u{2ABC}"), + ("Scaron", "\u{0160}"), + ("Scedil", "\u{015E}"), + ("Scirc", "\u{015C}"), + ("Scy", "\u{0421}"), + ("Sfr", "\u{1D516}"), + ("ShortDownArrow", "\u{2193}"), + ("ShortLeftArrow", "\u{2190}"), + ("ShortRightArrow", "\u{2192}"), + ("ShortUpArrow", "\u{2191}"), + ("Sigma", "\u{03A3}"), + ("SmallCircle", "\u{2218}"), + ("Sopf", "\u{1D54A}"), + ("Sqrt", "\u{221A}"), + ("Square", "\u{25A1}"), + ("SquareIntersection", "\u{2293}"), + ("SquareSubset", "\u{228F}"), + ("SquareSubsetEqual", "\u{2291}"), + ("SquareSuperset", "\u{2290}"), + ("SquareSupersetEqual", "\u{2292}"), + ("SquareUnion", "\u{2294}"), + ("Sscr", "\u{1D4AE}"), + ("Star", "\u{22C6}"), + ("Sub", "\u{22D0}"), + ("Subset", "\u{22D0}"), + ("SubsetEqual", "\u{2286}"), + ("Succeeds", "\u{227B}"), + ("SucceedsEqual", "\u{2AB0}"), + ("SucceedsSlantEqual", "\u{227D}"), + ("SucceedsTilde", "\u{227F}"), + ("SuchThat", "\u{220B}"), + ("Sum", "\u{2211}"), + ("Sup", "\u{22D1}"), + ("Superset", "\u{2283}"), + ("SupersetEqual", "\u{2287}"), + ("Supset", "\u{22D1}"), + ("THORN", "\u{00DE}"), + ("TRADE", "\u{2122}"), + ("TSHcy", "\u{040B}"), + ("TScy", "\u{0426}"), + ("Tab", "\t"), + ("Tau", "\u{03A4}"), + ("Tcaron", "\u{0164}"), + ("Tcedil", "\u{0162}"), + ("Tcy", "\u{0422}"), + ("Tfr", "\u{1D517}"), + ("Therefore", "\u{2234}"), + ("Theta", "\u{0398}"), + ("ThickSpace", "\u{205F}\u{200A}"), + ("ThinSpace", "\u{2009}"), + ("Tilde", "\u{223C}"), + ("TildeEqual", "\u{2243}"), + ("TildeFullEqual", "\u{2245}"), + ("TildeTilde", "\u{2248}"), + ("Topf", "\u{1D54B}"), + ("TripleDot", "\u{20DB}"), + ("Tscr", "\u{1D4AF}"), + ("Tstrok", "\u{0166}"), + ("Uacute", "\u{00DA}"), + ("Uarr", "\u{219F}"), + ("Uarrocir", "\u{2949}"), + ("Ubrcy", "\u{040E}"), + ("Ubreve", "\u{016C}"), + ("Ucirc", "\u{00DB}"), + ("Ucy", "\u{0423}"), + ("Udblac", "\u{0170}"), + ("Ufr", "\u{1D518}"), + ("Ugrave", "\u{00D9}"), + ("Umacr", "\u{016A}"), + ("UnderBar", "\u{005F}"), + ("UnderBrace", "\u{23DF}"), + ("UnderBracket", "\u{23B5}"), + ("UnderParenthesis", "\u{23DD}"), + ("Union", "\u{22C3}"), + ("UnionPlus", "\u{228E}"), + ("Uogon", "\u{0172}"), + ("Uopf", "\u{1D54C}"), + ("UpArrow", "\u{2191}"), + ("UpArrowBar", "\u{2912}"), + ("UpArrowDownArrow", "\u{21C5}"), + ("UpDownArrow", "\u{2195}"), + ("UpEquilibrium", "\u{296E}"), + ("UpTee", "\u{22A5}"), + ("UpTeeArrow", "\u{21A5}"), + ("Uparrow", "\u{21D1}"), + ("Updownarrow", "\u{21D5}"), + ("UpperLeftArrow", "\u{2196}"), + ("UpperRightArrow", "\u{2197}"), + ("Upsi", "\u{03D2}"), + ("Upsilon", "\u{03A5}"), + ("Uring", "\u{016E}"), + ("Uscr", "\u{1D4B0}"), + ("Utilde", "\u{0168}"), + ("Uuml", "\u{00DC}"), + ("VDash", "\u{22AB}"), + ("Vbar", "\u{2AEB}"), + ("Vcy", "\u{0412}"), + ("Vdash", "\u{22A9}"), + ("Vdashl", "\u{2AE6}"), + ("Vee", "\u{22C1}"), + ("Verbar", "\u{2016}"), + ("Vert", "\u{2016}"), + ("VerticalBar", "\u{2223}"), + ("VerticalLine", "\u{007C}"), + ("VerticalSeparator", "\u{2758}"), + ("VerticalTilde", "\u{2240}"), + ("VeryThinSpace", "\u{200A}"), + ("Vfr", "\u{1D519}"), + ("Vopf", "\u{1D54D}"), + ("Vscr", "\u{1D4B1}"), + ("Vvdash", "\u{22AA}"), + ("Wcirc", "\u{0174}"), + ("Wedge", "\u{22C0}"), + ("Wfr", "\u{1D51A}"), + ("Wopf", "\u{1D54E}"), + ("Wscr", "\u{1D4B2}"), + ("Xfr", "\u{1D51B}"), + ("Xi", "\u{039E}"), + ("Xopf", "\u{1D54F}"), + ("Xscr", "\u{1D4B3}"), + ("YAcy", "\u{042F}"), + ("YIcy", "\u{0407}"), + ("YUcy", "\u{042E}"), + ("Yacute", "\u{00DD}"), + ("Ycirc", "\u{0176}"), + ("Ycy", "\u{042B}"), + ("Yfr", "\u{1D51C}"), + ("Yopf", "\u{1D550}"), + ("Yscr", "\u{1D4B4}"), + ("Yuml", "\u{0178}"), + ("ZHcy", "\u{0416}"), + ("Zacute", "\u{0179}"), + ("Zcaron", "\u{017D}"), + ("Zcy", "\u{0417}"), + ("Zdot", "\u{017B}"), + ("ZeroWidthSpace", "\u{200B}"), + ("Zeta", "\u{0396}"), + ("Zfr", "\u{2128}"), + ("Zopf", "\u{2124}"), + ("Zscr", "\u{1D4B5}"), + ("aacute", "\u{00E1}"), + ("abreve", "\u{0103}"), + ("ac", "\u{223E}"), + ("acE", "\u{223E}\u{0333}"), + ("acd", "\u{223F}"), + ("acirc", "\u{00E2}"), + ("acute", "\u{00B4}"), + ("acy", "\u{0430}"), + ("aelig", "\u{00E6}"), + ("af", "\u{2061}"), + ("afr", "\u{1D51E}"), + ("agrave", "\u{00E0}"), + ("alefsym", "\u{2135}"), + ("aleph", "\u{2135}"), + ("alpha", "\u{03B1}"), + ("amacr", "\u{0101}"), + ("amalg", "\u{2A3F}"), + ("amp", "&"), + ("and", "\u{2227}"), + ("andand", "\u{2A55}"), + ("andd", "\u{2A5C}"), + ("andslope", "\u{2A58}"), + ("andv", "\u{2A5A}"), + ("ang", "\u{2220}"), + ("ange", "\u{29A4}"), + ("angle", "\u{2220}"), + ("angmsd", "\u{2221}"), + ("angmsdaa", "\u{29A8}"), + ("angmsdab", "\u{29A9}"), + ("angmsdac", "\u{29AA}"), + ("angmsdad", "\u{29AB}"), + ("angmsdae", "\u{29AC}"), + ("angmsdaf", "\u{29AD}"), + ("angmsdag", "\u{29AE}"), + ("angmsdah", "\u{29AF}"), + ("angrt", "\u{221F}"), + ("angrtvb", "\u{22BE}"), + ("angrtvbd", "\u{299D}"), + ("angsph", "\u{2222}"), + ("angst", "\u{00C5}"), + ("angzarr", "\u{237C}"), + ("aogon", "\u{0105}"), + ("aopf", "\u{1D552}"), + ("ap", "\u{2248}"), + ("apE", "\u{2A70}"), + ("apacir", "\u{2A6F}"), + ("ape", "\u{224A}"), + ("apid", "\u{224B}"), + ("apos", "'"), + ("approx", "\u{2248}"), + ("approxeq", "\u{224A}"), + ("aring", "\u{00E5}"), + ("ascr", "\u{1D4B6}"), + ("ast", "\u{002A}"), + ("asymp", "\u{2248}"), + ("asympeq", "\u{224D}"), + ("atilde", "\u{00E3}"), + ("auml", "\u{00E4}"), + ("awconint", "\u{2233}"), + ("awint", "\u{2A11}"), + ("bNot", "\u{2AED}"), + ("backcong", "\u{224C}"), + ("backepsilon", "\u{03F6}"), + ("backprime", "\u{2035}"), + ("backsim", "\u{223D}"), + ("backsimeq", "\u{22CD}"), + ("barvee", "\u{22BD}"), + ("barwed", "\u{2305}"), + ("barwedge", "\u{2305}"), + ("bbrk", "\u{23B5}"), + ("bbrktbrk", "\u{23B6}"), + ("bcong", "\u{224C}"), + ("bcy", "\u{0431}"), + ("bdquo", "\u{201E}"), + ("becaus", "\u{2235}"), + ("because", "\u{2235}"), + ("bemptyv", "\u{29B0}"), + ("bepsi", "\u{03F6}"), + ("bernou", "\u{212C}"), + ("beta", "\u{03B2}"), + ("beth", "\u{2136}"), + ("between", "\u{226C}"), + ("bfr", "\u{1D51F}"), + ("bigcap", "\u{22C2}"), + ("bigcirc", "\u{25EF}"), + ("bigcup", "\u{22C3}"), + ("bigodot", "\u{2A00}"), + ("bigoplus", "\u{2A01}"), + ("bigotimes", "\u{2A02}"), + ("bigsqcup", "\u{2A06}"), + ("bigstar", "\u{2605}"), + ("bigtriangledown", "\u{25BD}"), + ("bigtriangleup", "\u{25B3}"), + ("biguplus", "\u{2A04}"), + ("bigvee", "\u{22C1}"), + ("bigwedge", "\u{22C0}"), + ("bkarow", "\u{290D}"), + ("blacklozenge", "\u{29EB}"), + ("blacksquare", "\u{25AA}"), + ("blacktriangle", "\u{25B4}"), + ("blacktriangledown", "\u{25BE}"), + ("blacktriangleleft", "\u{25C2}"), + ("blacktriangleright", "\u{25B8}"), + ("blank", "\u{2423}"), + ("blk12", "\u{2592}"), + ("blk14", "\u{2591}"), + ("blk34", "\u{2593}"), + ("block", "\u{2588}"), + ("bne", "\u{003D}\u{20E5}"), + ("bnequiv", "\u{2261}\u{20E5}"), + ("bnot", "\u{2310}"), + ("bopf", "\u{1D553}"), + ("bot", "\u{22A5}"), + ("bottom", "\u{22A5}"), + ("bowtie", "\u{22C8}"), + ("boxDL", "\u{2557}"), + ("boxDR", "\u{2554}"), + ("boxDl", "\u{2556}"), + ("boxDr", "\u{2553}"), + ("boxH", "\u{2550}"), + ("boxHD", "\u{2566}"), + ("boxHU", "\u{2569}"), + ("boxHd", "\u{2564}"), + ("boxHu", "\u{2567}"), + ("boxUL", "\u{255D}"), + ("boxUR", "\u{255A}"), + ("boxUl", "\u{255C}"), + ("boxUr", "\u{2559}"), + ("boxV", "\u{2551}"), + ("boxVH", "\u{256C}"), + ("boxVL", "\u{2563}"), + ("boxVR", "\u{2560}"), + ("boxVh", "\u{256B}"), + ("boxVl", "\u{2562}"), + ("boxVr", "\u{255F}"), + ("boxbox", "\u{29C9}"), + ("boxdL", "\u{2555}"), + ("boxdR", "\u{2552}"), + ("boxdl", "\u{2510}"), + ("boxdr", "\u{250C}"), + ("boxh", "\u{2500}"), + ("boxhD", "\u{2565}"), + ("boxhU", "\u{2568}"), + ("boxhd", "\u{252C}"), + ("boxhu", "\u{2534}"), + ("boxminus", "\u{229F}"), + ("boxplus", "\u{229E}"), + ("boxtimes", "\u{22A0}"), + ("boxuL", "\u{255B}"), + ("boxuR", "\u{2558}"), + ("boxul", "\u{2518}"), + ("boxur", "\u{2514}"), + ("boxv", "\u{2502}"), + ("boxvH", "\u{256A}"), + ("boxvL", "\u{2561}"), + ("boxvR", "\u{255E}"), + ("boxvh", "\u{253C}"), + ("boxvl", "\u{2524}"), + ("boxvr", "\u{251C}"), + ("bprime", "\u{2035}"), + ("breve", "\u{02D8}"), + ("brvbar", "\u{00A6}"), + ("bscr", "\u{1D4B7}"), + ("bsemi", "\u{204F}"), + ("bsim", "\u{223D}"), + ("bsime", "\u{22CD}"), + ("bsol", "\\"), + ("bsolb", "\u{29C5}"), + ("bsolhsub", "\u{27C8}"), + ("bull", "\u{2022}"), + ("bullet", "\u{2022}"), + ("bump", "\u{224E}"), + ("bumpE", "\u{2AAE}"), + ("bumpe", "\u{224F}"), + ("bumpeq", "\u{224F}"), + ("cacute", "\u{0107}"), + ("cap", "\u{2229}"), + ("capand", "\u{2A44}"), + ("capbrcup", "\u{2A49}"), + ("capcap", "\u{2A4B}"), + ("capcup", "\u{2A47}"), + ("capdot", "\u{2A40}"), + ("caps", "\u{2229}\u{FE00}"), + ("caret", "\u{2041}"), + ("caron", "\u{02C7}"), + ("ccaps", "\u{2A4D}"), + ("ccaron", "\u{010D}"), + ("ccedil", "\u{00E7}"), + ("ccirc", "\u{0109}"), + ("ccups", "\u{2A4C}"), + ("ccupssm", "\u{2A50}"), + ("cdot", "\u{010B}"), + ("cedil", "\u{00B8}"), + ("cemptyv", "\u{29B2}"), + ("cent", "\u{00A2}"), + ("centerdot", "\u{00B7}"), + ("cfr", "\u{1D520}"), + ("chcy", "\u{0447}"), + ("check", "\u{2713}"), + ("checkmark", "\u{2713}"), + ("chi", "\u{03C7}"), + ("cir", "\u{25CB}"), + ("cirE", "\u{29C3}"), + ("circ", "\u{02C6}"), + ("circeq", "\u{2257}"), + ("circlearrowleft", "\u{21BA}"), + ("circlearrowright", "\u{21BB}"), + ("circledR", "\u{00AE}"), + ("circledS", "\u{24C8}"), + ("circledast", "\u{229B}"), + ("circledcirc", "\u{229A}"), + ("circleddash", "\u{229D}"), + ("cire", "\u{2257}"), + ("cirfnint", "\u{2A10}"), + ("cirmid", "\u{2AEF}"), + ("cirscir", "\u{29C2}"), + ("clubs", "\u{2663}"), + ("clubsuit", "\u{2663}"), + ("colon", "\u{003A}"), + ("colone", "\u{2254}"), + ("coloneq", "\u{2254}"), + ("comma", "\u{002C}"), + ("commat", "\u{0040}"), + ("comp", "\u{2201}"), + ("compfn", "\u{2218}"), + ("complement", "\u{2201}"), + ("complexes", "\u{2102}"), + ("cong", "\u{2245}"), + ("congdot", "\u{2A6D}"), + ("conint", "\u{222E}"), + ("copf", "\u{1D554}"), + ("coprod", "\u{2210}"), + ("copy", "\u{00A9}"), + ("copysr", "\u{2117}"), + ("crarr", "\u{21B5}"), + ("cross", "\u{2717}"), + ("cscr", "\u{1D4B8}"), + ("csub", "\u{2ACF}"), + ("csube", "\u{2AD1}"), + ("csup", "\u{2AD0}"), + ("csupe", "\u{2AD2}"), + ("ctdot", "\u{22EF}"), + ("cudarrl", "\u{2938}"), + ("cudarrr", "\u{2935}"), + ("cuepr", "\u{22DE}"), + ("cuesc", "\u{22DF}"), + ("cularr", "\u{21B6}"), + ("cularrp", "\u{293D}"), + ("cup", "\u{222A}"), + ("cupbrcap", "\u{2A48}"), + ("cupcap", "\u{2A46}"), + ("cupcup", "\u{2A4A}"), + ("cupdot", "\u{228D}"), + ("cupor", "\u{2A45}"), + ("cups", "\u{222A}\u{FE00}"), + ("curarr", "\u{21B7}"), + ("curarrm", "\u{293C}"), + ("curlyeqprec", "\u{22DE}"), + ("curlyeqsucc", "\u{22DF}"), + ("curlyvee", "\u{22CE}"), + ("curlywedge", "\u{22CF}"), + ("curren", "\u{00A4}"), + ("curvearrowleft", "\u{21B6}"), + ("curvearrowright", "\u{21B7}"), + ("cuvee", "\u{22CE}"), + ("cuwed", "\u{22CF}"), + ("cwconint", "\u{2232}"), + ("cwint", "\u{2231}"), + ("cylcty", "\u{232D}"), + ("dArr", "\u{21D3}"), + ("dHar", "\u{2965}"), + ("dagger", "\u{2020}"), + ("daleth", "\u{2138}"), + ("darr", "\u{2193}"), + ("dash", "\u{2010}"), + ("dashv", "\u{22A3}"), + ("dbkarow", "\u{290F}"), + ("dblac", "\u{02DD}"), + ("dcaron", "\u{010F}"), + ("dcy", "\u{0434}"), + ("dd", "\u{2146}"), + ("ddagger", "\u{2021}"), + ("ddarr", "\u{21CA}"), + ("ddotseq", "\u{2A77}"), + ("deg", "\u{00B0}"), + ("delta", "\u{03B4}"), + ("demptyv", "\u{29B1}"), + ("dfisht", "\u{297F}"), + ("dfr", "\u{1D521}"), + ("dharl", "\u{21C3}"), + ("dharr", "\u{21C2}"), + ("diam", "\u{22C4}"), + ("diamond", "\u{22C4}"), + ("diamondsuit", "\u{2666}"), + ("diams", "\u{2666}"), + ("die", "\u{00A8}"), + ("digamma", "\u{03DD}"), + ("disin", "\u{22F2}"), + ("div", "\u{00F7}"), + ("divide", "\u{00F7}"), + ("divideontimes", "\u{22C7}"), + ("divonx", "\u{22C7}"), + ("djcy", "\u{0452}"), + ("dlcorn", "\u{231E}"), + ("dlcrop", "\u{230D}"), + ("dollar", "\u{0024}"), + ("dopf", "\u{1D555}"), + ("dot", "\u{02D9}"), + ("doteq", "\u{2250}"), + ("doteqdot", "\u{2251}"), + ("dotminus", "\u{2238}"), + ("dotplus", "\u{2214}"), + ("dotsquare", "\u{22A1}"), + ("doublebarwedge", "\u{2306}"), + ("downarrow", "\u{2193}"), + ("downdownarrows", "\u{21CA}"), + ("downharpoonleft", "\u{21C3}"), + ("downharpoonright", "\u{21C2}"), + ("drbkarow", "\u{2910}"), + ("drcorn", "\u{231F}"), + ("drcrop", "\u{230C}"), + ("dscr", "\u{1D4B9}"), + ("dscy", "\u{0455}"), + ("dsol", "\u{29F6}"), + ("dstrok", "\u{0111}"), + ("dtdot", "\u{22F1}"), + ("dtri", "\u{25BF}"), + ("dtrif", "\u{25BE}"), + ("duarr", "\u{21F5}"), + ("duhar", "\u{296F}"), + ("dwangle", "\u{29A6}"), + ("dzcy", "\u{045F}"), + ("dzigrarr", "\u{27FF}"), + ("eDDot", "\u{2A77}"), + ("eDot", "\u{2251}"), + ("eacute", "\u{00E9}"), + ("easter", "\u{2A6E}"), + ("ecaron", "\u{011B}"), + ("ecir", "\u{2256}"), + ("ecirc", "\u{00EA}"), + ("ecolon", "\u{2255}"), + ("ecy", "\u{044D}"), + ("edot", "\u{0117}"), + ("ee", "\u{2147}"), + ("efDot", "\u{2252}"), + ("efr", "\u{1D522}"), + ("eg", "\u{2A9A}"), + ("egrave", "\u{00E8}"), + ("egs", "\u{2A96}"), + ("egsdot", "\u{2A98}"), + ("el", "\u{2A99}"), + ("elinters", "\u{23E7}"), + ("ell", "\u{2113}"), + ("els", "\u{2A95}"), + ("elsdot", "\u{2A97}"), + ("emacr", "\u{0113}"), + ("empty", "\u{2205}"), + ("emptyset", "\u{2205}"), + ("emptyv", "\u{2205}"), + ("emsp", "\u{2003}"), + ("emsp13", "\u{2004}"), + ("emsp14", "\u{2005}"), + ("eng", "\u{014B}"), + ("ensp", "\u{2002}"), + ("eogon", "\u{0119}"), + ("eopf", "\u{1D556}"), + ("epar", "\u{22D5}"), + ("eparsl", "\u{29E3}"), + ("eplus", "\u{2A71}"), + ("epsi", "\u{03B5}"), + ("epsilon", "\u{03B5}"), + ("epsiv", "\u{03F5}"), + ("eqcirc", "\u{2256}"), + ("eqcolon", "\u{2255}"), + ("eqsim", "\u{2242}"), + ("eqslantgtr", "\u{2A96}"), + ("eqslantless", "\u{2A95}"), + ("equals", "\u{003D}"), + ("equest", "\u{225F}"), + ("equiv", "\u{2261}"), + ("equivDD", "\u{2A78}"), + ("eqvparsl", "\u{29E5}"), + ("erDot", "\u{2253}"), + ("erarr", "\u{2971}"), + ("escr", "\u{212F}"), + ("esdot", "\u{2250}"), + ("esim", "\u{2242}"), + ("eta", "\u{03B7}"), + ("eth", "\u{00F0}"), + ("euml", "\u{00EB}"), + ("euro", "\u{20AC}"), + ("excl", "\u{0021}"), + ("exist", "\u{2203}"), + ("expectation", "\u{2130}"), + ("exponentiale", "\u{2147}"), + ("fallingdotseq", "\u{2252}"), + ("fcy", "\u{0444}"), + ("female", "\u{2640}"), + ("ffilig", "\u{FB03}"), + ("fflig", "\u{FB00}"), + ("ffllig", "\u{FB04}"), + ("ffr", "\u{1D523}"), + ("filig", "\u{FB01}"), + ("fjlig", "\u{0066}\u{006A}"), + ("flat", "\u{266D}"), + ("fllig", "\u{FB02}"), + ("fltns", "\u{25B1}"), + ("fnof", "\u{0192}"), + ("fopf", "\u{1D557}"), + ("forall", "\u{2200}"), + ("fork", "\u{22D4}"), + ("forkv", "\u{2AD9}"), + ("fpartint", "\u{2A0D}"), + ("frac12", "\u{00BD}"), + ("frac13", "\u{2153}"), + ("frac14", "\u{00BC}"), + ("frac15", "\u{2155}"), + ("frac16", "\u{2159}"), + ("frac18", "\u{215B}"), + ("frac23", "\u{2154}"), + ("frac25", "\u{2156}"), + ("frac34", "\u{00BE}"), + ("frac35", "\u{2157}"), + ("frac38", "\u{215C}"), + ("frac45", "\u{2158}"), + ("frac56", "\u{215A}"), + ("frac58", "\u{215D}"), + ("frac78", "\u{215E}"), + ("frasl", "\u{2044}"), + ("frown", "\u{2322}"), + ("fscr", "\u{1D4BB}"), + ("gE", "\u{2267}"), + ("gEl", "\u{2A8C}"), + ("gacute", "\u{01F5}"), + ("gamma", "\u{03B3}"), + ("gammad", "\u{03DD}"), + ("gap", "\u{2A86}"), + ("gbreve", "\u{011F}"), + ("gcirc", "\u{011D}"), + ("gcy", "\u{0433}"), + ("gdot", "\u{0121}"), + ("ge", "\u{2265}"), + ("gel", "\u{22DB}"), + ("geq", "\u{2265}"), + ("geqq", "\u{2267}"), + ("geqslant", "\u{2A7E}"), + ("ges", "\u{2A7E}"), + ("gescc", "\u{2AA9}"), + ("gesdot", "\u{2A80}"), + ("gesdoto", "\u{2A82}"), + ("gesdotol", "\u{2A84}"), + ("gesl", "\u{22DB}\u{FE00}"), + ("gesles", "\u{2A94}"), + ("gfr", "\u{1D524}"), + ("gg", "\u{226B}"), + ("ggg", "\u{22D9}"), + ("gimel", "\u{2137}"), + ("gjcy", "\u{0453}"), + ("gl", "\u{2277}"), + ("glE", "\u{2A92}"), + ("gla", "\u{2AA5}"), + ("glj", "\u{2AA4}"), + ("gnE", "\u{2269}"), + ("gnap", "\u{2A8A}"), + ("gnapprox", "\u{2A8A}"), + ("gne", "\u{2A88}"), + ("gneq", "\u{2A88}"), + ("gneqq", "\u{2269}"), + ("gnsim", "\u{22E7}"), + ("gopf", "\u{1D558}"), + ("grave", "\u{0060}"), + ("gscr", "\u{210A}"), + ("gsim", "\u{2273}"), + ("gsime", "\u{2A8E}"), + ("gsiml", "\u{2A90}"), + ("gt", ">"), + ("gtcc", "\u{2AA7}"), + ("gtcir", "\u{2A7A}"), + ("gtdot", "\u{22D7}"), + ("gtlPar", "\u{2995}"), + ("gtquest", "\u{2A7C}"), + ("gtrapprox", "\u{2A86}"), + ("gtrarr", "\u{2978}"), + ("gtrdot", "\u{22D7}"), + ("gtreqless", "\u{22DB}"), + ("gtreqqless", "\u{2A8C}"), + ("gtrless", "\u{2277}"), + ("gtrsim", "\u{2273}"), + ("gvertneqq", "\u{2269}\u{FE00}"), + ("gvnE", "\u{2269}\u{FE00}"), + ("hArr", "\u{21D4}"), + ("hairsp", "\u{200A}"), + ("half", "\u{00BD}"), + ("hamilt", "\u{210B}"), + ("hardcy", "\u{044A}"), + ("harr", "\u{2194}"), + ("harrcir", "\u{2948}"), + ("harrw", "\u{21AD}"), + ("hbar", "\u{210F}"), + ("hcirc", "\u{0125}"), + ("hearts", "\u{2665}"), + ("heartsuit", "\u{2665}"), + ("hellip", "\u{2026}"), + ("hercon", "\u{22B9}"), + ("hfr", "\u{1D525}"), + ("hksearow", "\u{2925}"), + ("hkswarow", "\u{2926}"), + ("hoarr", "\u{21FF}"), + ("homtht", "\u{223B}"), + ("hookleftarrow", "\u{21A9}"), + ("hookrightarrow", "\u{21AA}"), + ("hopf", "\u{1D559}"), + ("horbar", "\u{2015}"), + ("hscr", "\u{1D4BD}"), + ("hslash", "\u{210F}"), + ("hstrok", "\u{0127}"), + ("hybull", "\u{2043}"), + ("hyphen", "\u{2010}"), + ("iacute", "\u{00ED}"), + ("ic", "\u{2063}"), + ("icirc", "\u{00EE}"), + ("icy", "\u{0438}"), + ("iecy", "\u{0435}"), + ("iexcl", "\u{00A1}"), + ("iff", "\u{21D4}"), + ("ifr", "\u{1D526}"), + ("igrave", "\u{00EC}"), + ("ii", "\u{2148}"), + ("iiiint", "\u{2A0C}"), + ("iiint", "\u{222D}"), + ("iinfin", "\u{29DC}"), + ("iiota", "\u{2129}"), + ("ijlig", "\u{0133}"), + ("imacr", "\u{012B}"), + ("image", "\u{2111}"), + ("imagline", "\u{2110}"), + ("imagpart", "\u{2111}"), + ("imath", "\u{0131}"), + ("imof", "\u{22B7}"), + ("imped", "\u{01B5}"), + ("in", "\u{2208}"), + ("incare", "\u{2105}"), + ("infin", "\u{221E}"), + ("infintie", "\u{29DD}"), + ("inodot", "\u{0131}"), + ("int", "\u{222B}"), + ("intcal", "\u{22BA}"), + ("integers", "\u{2124}"), + ("intercal", "\u{22BA}"), + ("intlarhk", "\u{2A17}"), + ("intprod", "\u{2A3C}"), + ("iocy", "\u{0451}"), + ("iogon", "\u{012F}"), + ("iopf", "\u{1D55A}"), + ("iota", "\u{03B9}"), + ("iprod", "\u{2A3C}"), + ("iquest", "\u{00BF}"), + ("iscr", "\u{1D4BE}"), + ("isin", "\u{2208}"), + ("isinE", "\u{22F9}"), + ("isindot", "\u{22F5}"), + ("isins", "\u{22F4}"), + ("isinsv", "\u{22F3}"), + ("isinv", "\u{2208}"), + ("it", "\u{2062}"), + ("itilde", "\u{0129}"), + ("iukcy", "\u{0456}"), + ("iuml", "\u{00EF}"), + ("jcirc", "\u{0135}"), + ("jcy", "\u{0439}"), + ("jfr", "\u{1D527}"), + ("jmath", "\u{0237}"), + ("jopf", "\u{1D55B}"), + ("jscr", "\u{1D4BF}"), + ("jsercy", "\u{0458}"), + ("jukcy", "\u{0454}"), + ("kappa", "\u{03BA}"), + ("kappav", "\u{03F0}"), + ("kcedil", "\u{0137}"), + ("kcy", "\u{043A}"), + ("kfr", "\u{1D528}"), + ("kgreen", "\u{0138}"), + ("khcy", "\u{0445}"), + ("kjcy", "\u{045C}"), + ("kopf", "\u{1D55C}"), + ("kscr", "\u{1D4C0}"), + ("lAarr", "\u{21DA}"), + ("lArr", "\u{21D0}"), + ("lAtail", "\u{291B}"), + ("lBarr", "\u{290E}"), + ("lE", "\u{2266}"), + ("lEg", "\u{2A8B}"), + ("lHar", "\u{2962}"), + ("lacute", "\u{013A}"), + ("laemptyv", "\u{29B4}"), + ("lagran", "\u{2112}"), + ("lambda", "\u{03BB}"), + ("lang", "\u{27E8}"), + ("langd", "\u{2991}"), + ("langle", "\u{27E8}"), + ("lap", "\u{2A85}"), + ("laquo", "\u{00AB}"), + ("larr", "\u{2190}"), + ("larrb", "\u{21E4}"), + ("larrbfs", "\u{291F}"), + ("larrfs", "\u{291D}"), + ("larrhk", "\u{21A9}"), + ("larrlp", "\u{21AB}"), + ("larrpl", "\u{2939}"), + ("larrsim", "\u{2973}"), + ("larrtl", "\u{21A2}"), + ("lat", "\u{2AAB}"), + ("latail", "\u{2919}"), + ("late", "\u{2AAD}"), + ("lates", "\u{2AAD}\u{FE00}"), + ("lbarr", "\u{290C}"), + ("lbbrk", "\u{2772}"), + ("lbrace", "\u{007B}"), + ("lbrack", "\u{005B}"), + ("lbrke", "\u{298B}"), + ("lbrksld", "\u{298F}"), + ("lbrkslu", "\u{298D}"), + ("lcaron", "\u{013E}"), + ("lcedil", "\u{013C}"), + ("lceil", "\u{2308}"), + ("lcub", "\u{007B}"), + ("lcy", "\u{043B}"), + ("ldca", "\u{2936}"), + ("ldquo", "\u{201C}"), + ("ldquor", "\u{201E}"), + ("ldrdhar", "\u{2967}"), + ("ldrushar", "\u{294B}"), + ("ldsh", "\u{21B2}"), + ("le", "\u{2264}"), + ("leftarrow", "\u{2190}"), + ("leftarrowtail", "\u{21A2}"), + ("leftharpoondown", "\u{21BD}"), + ("leftharpoonup", "\u{21BC}"), + ("leftleftarrows", "\u{21C7}"), + ("leftrightarrow", "\u{2194}"), + ("leftrightarrows", "\u{21C6}"), + ("leftrightharpoons", "\u{21CB}"), + ("leftrightsquigarrow", "\u{21AD}"), + ("leftthreetimes", "\u{22CB}"), + ("leg", "\u{22DA}"), + ("leq", "\u{2264}"), + ("leqq", "\u{2266}"), + ("leqslant", "\u{2A7D}"), + ("les", "\u{2A7D}"), + ("lescc", "\u{2AA8}"), + ("lesdot", "\u{2A7F}"), + ("lesdoto", "\u{2A81}"), + ("lesdotor", "\u{2A83}"), + ("lesg", "\u{22DA}\u{FE00}"), + ("lesges", "\u{2A93}"), + ("lessapprox", "\u{2A85}"), + ("lessdot", "\u{22D6}"), + ("lesseqgtr", "\u{22DA}"), + ("lesseqqgtr", "\u{2A8B}"), + ("lessgtr", "\u{2276}"), + ("lesssim", "\u{2272}"), + ("lfisht", "\u{297C}"), + ("lfloor", "\u{230A}"), + ("lfr", "\u{1D529}"), + ("lg", "\u{2276}"), + ("lgE", "\u{2A91}"), + ("lhard", "\u{21BD}"), + ("lharu", "\u{21BC}"), + ("lharul", "\u{296A}"), + ("lhblk", "\u{2584}"), + ("ljcy", "\u{0459}"), + ("ll", "\u{226A}"), + ("llarr", "\u{21C7}"), + ("llcorner", "\u{231E}"), + ("llhard", "\u{296B}"), + ("lltri", "\u{25FA}"), + ("lmidot", "\u{0140}"), + ("lmoust", "\u{23B0}"), + ("lmoustache", "\u{23B0}"), + ("lnE", "\u{2268}"), + ("lnap", "\u{2A89}"), + ("lnapprox", "\u{2A89}"), + ("lne", "\u{2A87}"), + ("lneq", "\u{2A87}"), + ("lneqq", "\u{2268}"), + ("lnsim", "\u{22E6}"), + ("loang", "\u{27EC}"), + ("loarr", "\u{21FD}"), + ("lobrk", "\u{27E6}"), + ("longleftarrow", "\u{27F5}"), + ("longleftrightarrow", "\u{27F7}"), + ("longmapsto", "\u{27FC}"), + ("longrightarrow", "\u{27F6}"), + ("looparrowleft", "\u{21AB}"), + ("looparrowright", "\u{21AC}"), + ("lopar", "\u{2985}"), + ("lopf", "\u{1D55D}"), + ("loplus", "\u{2A2D}"), + ("lotimes", "\u{2A34}"), + ("lowast", "\u{2217}"), + ("lowbar", "\u{005F}"), + ("loz", "\u{25CA}"), + ("lozenge", "\u{25CA}"), + ("lozf", "\u{29EB}"), + ("lpar", "\u{0028}"), + ("lparlt", "\u{2993}"), + ("lrarr", "\u{21C6}"), + ("lrcorner", "\u{231F}"), + ("lrhar", "\u{21CB}"), + ("lrhard", "\u{296D}"), + ("lrm", "\u{200E}"), + ("lrtri", "\u{22BF}"), + ("lsaquo", "\u{2039}"), + ("lscr", "\u{1D4C1}"), + ("lsh", "\u{21B0}"), + ("lsim", "\u{2272}"), + ("lsime", "\u{2A8D}"), + ("lsimg", "\u{2A8F}"), + ("lsqb", "\u{005B}"), + ("lsquo", "\u{2018}"), + ("lsquor", "\u{201A}"), + ("lstrok", "\u{0142}"), + ("lt", "<"), + ("ltcc", "\u{2AA6}"), + ("ltcir", "\u{2A79}"), + ("ltdot", "\u{22D6}"), + ("lthree", "\u{22CB}"), + ("ltimes", "\u{22C9}"), + ("ltlarr", "\u{2976}"), + ("ltquest", "\u{2A7B}"), + ("ltrPar", "\u{2996}"), + ("ltri", "\u{25C3}"), + ("ltrie", "\u{22B4}"), + ("ltrif", "\u{25C2}"), + ("lurdshar", "\u{294A}"), + ("luruhar", "\u{2966}"), + ("lvertneqq", "\u{2268}\u{FE00}"), + ("lvnE", "\u{2268}\u{FE00}"), + ("mDDot", "\u{223A}"), + ("macr", "\u{00AF}"), + ("male", "\u{2642}"), + ("malt", "\u{2720}"), + ("maltese", "\u{2720}"), + ("map", "\u{21A6}"), + ("mapsto", "\u{21A6}"), + ("mapstodown", "\u{21A7}"), + ("mapstoleft", "\u{21A4}"), + ("mapstoup", "\u{21A5}"), + ("marker", "\u{25AE}"), + ("mcomma", "\u{2A29}"), + ("mcy", "\u{043C}"), + ("mdash", "\u{2014}"), + ("measuredangle", "\u{2221}"), + ("mfr", "\u{1D52A}"), + ("mho", "\u{2127}"), + ("micro", "\u{00B5}"), + ("mid", "\u{2223}"), + ("midast", "\u{002A}"), + ("midcir", "\u{2AF0}"), + ("middot", "\u{00B7}"), + ("minus", "\u{2212}"), + ("minusb", "\u{229F}"), + ("minusd", "\u{2238}"), + ("minusdu", "\u{2A2A}"), + ("mlcp", "\u{2ADB}"), + ("mldr", "\u{2026}"), + ("mnplus", "\u{2213}"), + ("models", "\u{22A7}"), + ("mopf", "\u{1D55E}"), + ("mp", "\u{2213}"), + ("mscr", "\u{1D4C2}"), + ("mstpos", "\u{223E}"), + ("mu", "\u{03BC}"), + ("multimap", "\u{22B8}"), + ("mumap", "\u{22B8}"), + ("nGg", "\u{22D9}\u{0338}"), + ("nGt", "\u{226B}\u{20D2}"), + ("nGtv", "\u{226B}\u{0338}"), + ("nLeftarrow", "\u{21CD}"), + ("nLeftrightarrow", "\u{21CE}"), + ("nLl", "\u{22D8}\u{0338}"), + ("nLt", "\u{226A}\u{20D2}"), + ("nLtv", "\u{226A}\u{0338}"), + ("nRightarrow", "\u{21CF}"), + ("nVDash", "\u{22AF}"), + ("nVdash", "\u{22AE}"), + ("nabla", "\u{2207}"), + ("nacute", "\u{0144}"), + ("nang", "\u{2220}\u{20D2}"), + ("nap", "\u{2249}"), + ("napE", "\u{2A70}\u{0338}"), + ("napid", "\u{224B}\u{0338}"), + ("napos", "\u{0149}"), + ("napprox", "\u{2249}"), + ("natur", "\u{266E}"), + ("natural", "\u{266E}"), + ("naturals", "\u{2115}"), + ("nbsp", "\u{00A0}"), + ("nbump", "\u{224E}\u{0338}"), + ("nbumpe", "\u{224F}\u{0338}"), + ("ncap", "\u{2A43}"), + ("ncaron", "\u{0148}"), + ("ncedil", "\u{0146}"), + ("ncong", "\u{2247}"), + ("ncongdot", "\u{2A6D}\u{0338}"), + ("ncup", "\u{2A42}"), + ("ncy", "\u{043D}"), + ("ndash", "\u{2013}"), + ("ne", "\u{2260}"), + ("neArr", "\u{21D7}"), + ("nearhk", "\u{2924}"), + ("nearr", "\u{2197}"), + ("nearrow", "\u{2197}"), + ("nedot", "\u{2250}\u{0338}"), + ("nequiv", "\u{2262}"), + ("nesear", "\u{2928}"), + ("nesim", "\u{2242}\u{0338}"), + ("nexist", "\u{2204}"), + ("nexists", "\u{2204}"), + ("nfr", "\u{1D52B}"), + ("ngE", "\u{2267}\u{0338}"), + ("nge", "\u{2271}"), + ("ngeq", "\u{2271}"), + ("ngeqq", "\u{2267}\u{0338}"), + ("ngeqslant", "\u{2A7E}\u{0338}"), + ("nges", "\u{2A7E}\u{0338}"), + ("ngsim", "\u{2275}"), + ("ngt", "\u{226F}"), + ("ngtr", "\u{226F}"), + ("nhArr", "\u{21CE}"), + ("nharr", "\u{21AE}"), + ("nhpar", "\u{2AF2}"), + ("ni", "\u{220B}"), + ("nis", "\u{22FC}"), + ("nisd", "\u{22FA}"), + ("niv", "\u{220B}"), + ("njcy", "\u{045A}"), + ("nlArr", "\u{21CD}"), + ("nlE", "\u{2266}\u{0338}"), + ("nlarr", "\u{219A}"), + ("nldr", "\u{2025}"), + ("nle", "\u{2270}"), + ("nleftarrow", "\u{219A}"), + ("nleftrightarrow", "\u{21AE}"), + ("nleq", "\u{2270}"), + ("nleqq", "\u{2266}\u{0338}"), + ("nleqslant", "\u{2A7D}\u{0338}"), + ("nles", "\u{2A7D}\u{0338}"), + ("nless", "\u{226E}"), + ("nlsim", "\u{2274}"), + ("nlt", "\u{226E}"), + ("nltri", "\u{22EA}"), + ("nltrie", "\u{22EC}"), + ("nmid", "\u{2224}"), + ("nopf", "\u{1D55F}"), + ("not", "\u{00AC}"), + ("notin", "\u{2209}"), + ("notinE", "\u{22F9}\u{0338}"), + ("notindot", "\u{22F5}\u{0338}"), + ("notinva", "\u{2209}"), + ("notinvb", "\u{22F7}"), + ("notinvc", "\u{22F6}"), + ("notni", "\u{220C}"), + ("notniva", "\u{220C}"), + ("notnivb", "\u{22FE}"), + ("notnivc", "\u{22FD}"), + ("npar", "\u{2226}"), + ("nparallel", "\u{2226}"), + ("nparsl", "\u{2AFD}\u{20E5}"), + ("npart", "\u{2202}\u{0338}"), + ("npolint", "\u{2A14}"), + ("npr", "\u{2280}"), + ("nprcue", "\u{22E0}"), + ("npre", "\u{2AAF}\u{0338}"), + ("nprec", "\u{2280}"), + ("npreceq", "\u{2AAF}\u{0338}"), + ("nrArr", "\u{21CF}"), + ("nrarr", "\u{219B}"), + ("nrarrc", "\u{2933}\u{0338}"), + ("nrarrw", "\u{219D}\u{0338}"), + ("nrightarrow", "\u{219B}"), + ("nrtri", "\u{22EB}"), + ("nrtrie", "\u{22ED}"), + ("nsc", "\u{2281}"), + ("nsccue", "\u{22E1}"), + ("nsce", "\u{2AB0}\u{0338}"), + ("nscr", "\u{1D4C3}"), + ("nshortmid", "\u{2224}"), + ("nshortparallel", "\u{2226}"), + ("nsim", "\u{2241}"), + ("nsime", "\u{2244}"), + ("nsimeq", "\u{2244}"), + ("nsmid", "\u{2224}"), + ("nspar", "\u{2226}"), + ("nsqsube", "\u{22E2}"), + ("nsqsupe", "\u{22E3}"), + ("nsub", "\u{2284}"), + ("nsubE", "\u{2AC5}\u{0338}"), + ("nsube", "\u{2288}"), + ("nsubset", "\u{2282}\u{20D2}"), + ("nsubseteq", "\u{2288}"), + ("nsubseteqq", "\u{2AC5}\u{0338}"), + ("nsucc", "\u{2281}"), + ("nsucceq", "\u{2AB0}\u{0338}"), + ("nsup", "\u{2285}"), + ("nsupE", "\u{2AC6}\u{0338}"), + ("nsupe", "\u{2289}"), + ("nsupset", "\u{2283}\u{20D2}"), + ("nsupseteq", "\u{2289}"), + ("nsupseteqq", "\u{2AC6}\u{0338}"), + ("ntgl", "\u{2279}"), + ("ntilde", "\u{00F1}"), + ("ntlg", "\u{2278}"), + ("ntriangleleft", "\u{22EA}"), + ("ntrianglelefteq", "\u{22EC}"), + ("ntriangleright", "\u{22EB}"), + ("ntrianglerighteq", "\u{22ED}"), + ("nu", "\u{03BD}"), + ("num", "\u{0023}"), + ("numero", "\u{2116}"), + ("numsp", "\u{2007}"), + ("nvDash", "\u{22AD}"), + ("nvHarr", "\u{2904}"), + ("nvap", "\u{224D}\u{20D2}"), + ("nvdash", "\u{22AC}"), + ("nvge", "\u{2265}\u{20D2}"), + ("nvgt", ">\u{20D2}"), + ("nvinfin", "\u{29DE}"), + ("nvlArr", "\u{2902}"), + ("nvle", "\u{2264}\u{20D2}"), + ("nvlt", "<\u{20D2}"), + ("nvltrie", "\u{22B4}\u{20D2}"), + ("nvrArr", "\u{2903}"), + ("nvrtrie", "\u{22B5}\u{20D2}"), + ("nvsim", "\u{223C}\u{20D2}"), + ("nwArr", "\u{21D6}"), + ("nwarhk", "\u{2923}"), + ("nwarr", "\u{2196}"), + ("nwarrow", "\u{2196}"), + ("nwnear", "\u{2927}"), + ("oS", "\u{24C8}"), + ("oacute", "\u{00F3}"), + ("oast", "\u{229B}"), + ("ocir", "\u{229A}"), + ("ocirc", "\u{00F4}"), + ("ocy", "\u{043E}"), + ("odash", "\u{229D}"), + ("odblac", "\u{0151}"), + ("odiv", "\u{2A38}"), + ("odot", "\u{2299}"), + ("odsold", "\u{29BC}"), + ("oelig", "\u{0153}"), + ("ofcir", "\u{29BF}"), + ("ofr", "\u{1D52C}"), + ("ogon", "\u{02DB}"), + ("ograve", "\u{00F2}"), + ("ogt", "\u{29C1}"), + ("ohbar", "\u{29B5}"), + ("ohm", "\u{03A9}"), + ("oint", "\u{222E}"), + ("olarr", "\u{21BA}"), + ("olcir", "\u{29BE}"), + ("olcross", "\u{29BB}"), + ("oline", "\u{203E}"), + ("olt", "\u{29C0}"), + ("omacr", "\u{014D}"), + ("omega", "\u{03C9}"), + ("omicron", "\u{03BF}"), + ("omid", "\u{29B6}"), + ("ominus", "\u{2296}"), + ("oopf", "\u{1D560}"), + ("opar", "\u{29B7}"), + ("operp", "\u{29B9}"), + ("oplus", "\u{2295}"), + ("or", "\u{2228}"), + ("orarr", "\u{21BB}"), + ("ord", "\u{2A5D}"), + ("order", "\u{2134}"), + ("orderof", "\u{2134}"), + ("ordf", "\u{00AA}"), + ("ordm", "\u{00BA}"), + ("origof", "\u{22B6}"), + ("oror", "\u{2A56}"), + ("orslope", "\u{2A57}"), + ("orv", "\u{2A5B}"), + ("oscr", "\u{2134}"), + ("oslash", "\u{00F8}"), + ("osol", "\u{2298}"), + ("otilde", "\u{00F5}"), + ("otimes", "\u{2297}"), + ("otimesas", "\u{2A36}"), + ("ouml", "\u{00F6}"), + ("ovbar", "\u{233D}"), + ("par", "\u{2225}"), + ("para", "\u{00B6}"), + ("parallel", "\u{2225}"), + ("parsim", "\u{2AF3}"), + ("parsl", "\u{2AFD}"), + ("part", "\u{2202}"), + ("pcy", "\u{043F}"), + ("percnt", "\u{0025}"), + ("period", "\u{002E}"), + ("permil", "\u{2030}"), + ("perp", "\u{22A5}"), + ("pertenk", "\u{2031}"), + ("pfr", "\u{1D52D}"), + ("phi", "\u{03C6}"), + ("phiv", "\u{03D5}"), + ("phmmat", "\u{2133}"), + ("phone", "\u{260E}"), + ("pi", "\u{03C0}"), + ("pitchfork", "\u{22D4}"), + ("piv", "\u{03D6}"), + ("planck", "\u{210F}"), + ("planckh", "\u{210E}"), + ("plankv", "\u{210F}"), + ("plus", "\u{002B}"), + ("plusacir", "\u{2A23}"), + ("plusb", "\u{229E}"), + ("pluscir", "\u{2A22}"), + ("plusdo", "\u{2214}"), + ("plusdu", "\u{2A25}"), + ("pluse", "\u{2A72}"), + ("plusmn", "\u{00B1}"), + ("plussim", "\u{2A26}"), + ("plustwo", "\u{2A27}"), + ("pm", "\u{00B1}"), + ("pointint", "\u{2A15}"), + ("popf", "\u{1D561}"), + ("pound", "\u{00A3}"), + ("pr", "\u{227A}"), + ("prE", "\u{2AB3}"), + ("prap", "\u{2AB7}"), + ("prcue", "\u{227C}"), + ("pre", "\u{2AAF}"), + ("prec", "\u{227A}"), + ("precapprox", "\u{2AB7}"), + ("preccurlyeq", "\u{227C}"), + ("preceq", "\u{2AAF}"), + ("precnapprox", "\u{2AB9}"), + ("precneqq", "\u{2AB5}"), + ("precnsim", "\u{22E8}"), + ("precsim", "\u{227E}"), + ("prime", "\u{2032}"), + ("primes", "\u{2119}"), + ("prnE", "\u{2AB5}"), + ("prnap", "\u{2AB9}"), + ("prnsim", "\u{22E8}"), + ("prod", "\u{220F}"), + ("profalar", "\u{232E}"), + ("profline", "\u{2312}"), + ("profsurf", "\u{2313}"), + ("prop", "\u{221D}"), + ("propto", "\u{221D}"), + ("prsim", "\u{227E}"), + ("prurel", "\u{22B0}"), + ("pscr", "\u{1D4C5}"), + ("psi", "\u{03C8}"), + ("puncsp", "\u{2008}"), + ("qfr", "\u{1D52E}"), + ("qint", "\u{2A0C}"), + ("qopf", "\u{1D562}"), + ("qprime", "\u{2057}"), + ("qscr", "\u{1D4C6}"), + ("quaternions", "\u{210D}"), + ("quatint", "\u{2A16}"), + ("quest", "\u{003F}"), + ("questeq", "\u{225F}"), + ("quot", "\""), + ("rAarr", "\u{21DB}"), + ("rArr", "\u{21D2}"), + ("rAtail", "\u{291C}"), + ("rBarr", "\u{290F}"), + ("rHar", "\u{2964}"), + ("race", "\u{223D}\u{0331}"), + ("racute", "\u{0155}"), + ("radic", "\u{221A}"), + ("raemptyv", "\u{29B3}"), + ("rang", "\u{27E9}"), + ("rangd", "\u{2992}"), + ("range", "\u{29A5}"), + ("rangle", "\u{27E9}"), + ("raquo", "\u{00BB}"), + ("rarr", "\u{2192}"), + ("rarrap", "\u{2975}"), + ("rarrb", "\u{21E5}"), + ("rarrbfs", "\u{2920}"), + ("rarrc", "\u{2933}"), + ("rarrfs", "\u{291E}"), + ("rarrhk", "\u{21AA}"), + ("rarrlp", "\u{21AC}"), + ("rarrpl", "\u{2945}"), + ("rarrsim", "\u{2974}"), + ("rarrtl", "\u{21A3}"), + ("rarrw", "\u{219D}"), + ("ratail", "\u{291A}"), + ("ratio", "\u{2236}"), + ("rationals", "\u{211A}"), + ("rbarr", "\u{290D}"), + ("rbbrk", "\u{2773}"), + ("rbrace", "\u{007D}"), + ("rbrack", "\u{005D}"), + ("rbrke", "\u{298C}"), + ("rbrksld", "\u{298E}"), + ("rbrkslu", "\u{2990}"), + ("rcaron", "\u{0159}"), + ("rcedil", "\u{0157}"), + ("rceil", "\u{2309}"), + ("rcub", "\u{007D}"), + ("rcy", "\u{0440}"), + ("rdca", "\u{2937}"), + ("rdldhar", "\u{2969}"), + ("rdquo", "\u{201D}"), + ("rdquor", "\u{201D}"), + ("rdsh", "\u{21B3}"), + ("real", "\u{211C}"), + ("realine", "\u{211B}"), + ("realpart", "\u{211C}"), + ("reals", "\u{211D}"), + ("rect", "\u{25AD}"), + ("reg", "\u{00AE}"), + ("rfisht", "\u{297D}"), + ("rfloor", "\u{230B}"), + ("rfr", "\u{1D52F}"), + ("rhard", "\u{21C1}"), + ("rharu", "\u{21C0}"), + ("rharul", "\u{296C}"), + ("rho", "\u{03C1}"), + ("rhov", "\u{03F1}"), + ("rightarrow", "\u{2192}"), + ("rightarrowtail", "\u{21A3}"), + ("rightharpoondown", "\u{21C1}"), + ("rightharpoonup", "\u{21C0}"), + ("rightleftarrows", "\u{21C4}"), + ("rightleftharpoons", "\u{21CC}"), + ("rightrightarrows", "\u{21C9}"), + ("rightsquigarrow", "\u{219D}"), + ("rightthreetimes", "\u{22CC}"), + ("ring", "\u{02DA}"), + ("risingdotseq", "\u{2253}"), + ("rlarr", "\u{21C4}"), + ("rlhar", "\u{21CC}"), + ("rlm", "\u{200F}"), + ("rmoust", "\u{23B1}"), + ("rmoustache", "\u{23B1}"), + ("rnmid", "\u{2AEE}"), + ("roang", "\u{27ED}"), + ("roarr", "\u{21FE}"), + ("robrk", "\u{27E7}"), + ("ropar", "\u{2986}"), + ("ropf", "\u{1D563}"), + ("roplus", "\u{2A2E}"), + ("rotimes", "\u{2A35}"), + ("rpar", "\u{0029}"), + ("rpargt", "\u{2994}"), + ("rppolint", "\u{2A12}"), + ("rrarr", "\u{21C9}"), + ("rsaquo", "\u{203A}"), + ("rscr", "\u{1D4C7}"), + ("rsh", "\u{21B1}"), + ("rsqb", "\u{005D}"), + ("rsquo", "\u{2019}"), + ("rsquor", "\u{2019}"), + ("rthree", "\u{22CC}"), + ("rtimes", "\u{22CA}"), + ("rtri", "\u{25B9}"), + ("rtrie", "\u{22B5}"), + ("rtrif", "\u{25B8}"), + ("rtriltri", "\u{29CE}"), + ("ruluhar", "\u{2968}"), + ("rx", "\u{211E}"), + ("sacute", "\u{015B}"), + ("sbquo", "\u{201A}"), + ("sc", "\u{227B}"), + ("scE", "\u{2AB4}"), + ("scap", "\u{2AB8}"), + ("scaron", "\u{0161}"), + ("sccue", "\u{227D}"), + ("sce", "\u{2AB0}"), + ("scedil", "\u{015F}"), + ("scirc", "\u{015D}"), + ("scnE", "\u{2AB6}"), + ("scnap", "\u{2ABA}"), + ("scnsim", "\u{22E9}"), + ("scpolint", "\u{2A13}"), + ("scsim", "\u{227F}"), + ("scy", "\u{0441}"), + ("sdot", "\u{22C5}"), + ("sdotb", "\u{22A1}"), + ("sdote", "\u{2A66}"), + ("seArr", "\u{21D8}"), + ("searhk", "\u{2925}"), + ("searr", "\u{2198}"), + ("searrow", "\u{2198}"), + ("sect", "\u{00A7}"), + ("semi", "\u{003B}"), + ("seswar", "\u{2929}"), + ("setminus", "\u{2216}"), + ("setmn", "\u{2216}"), + ("sext", "\u{2736}"), + ("sfr", "\u{1D530}"), + ("sfrown", "\u{2322}"), + ("sharp", "\u{266F}"), + ("shchcy", "\u{0449}"), + ("shcy", "\u{0448}"), + ("shortmid", "\u{2223}"), + ("shortparallel", "\u{2225}"), + ("shy", "\u{00AD}"), + ("sigma", "\u{03C3}"), + ("sigmaf", "\u{03C2}"), + ("sigmav", "\u{03C2}"), + ("sim", "\u{223C}"), + ("simdot", "\u{2A6A}"), + ("sime", "\u{2243}"), + ("simeq", "\u{2243}"), + ("simg", "\u{2A9E}"), + ("simgE", "\u{2AA0}"), + ("siml", "\u{2A9D}"), + ("simlE", "\u{2A9F}"), + ("simne", "\u{2246}"), + ("simplus", "\u{2A24}"), + ("simrarr", "\u{2972}"), + ("slarr", "\u{2190}"), + ("smallsetminus", "\u{2216}"), + ("smashp", "\u{2A33}"), + ("smeparsl", "\u{29E4}"), + ("smid", "\u{2223}"), + ("smile", "\u{2323}"), + ("smt", "\u{2AAA}"), + ("smte", "\u{2AAC}"), + ("smtes", "\u{2AAC}\u{FE00}"), + ("softcy", "\u{044C}"), + ("sol", "\u{002F}"), + ("solb", "\u{29C4}"), + ("solbar", "\u{233F}"), + ("sopf", "\u{1D564}"), + ("spades", "\u{2660}"), + ("spadesuit", "\u{2660}"), + ("spar", "\u{2225}"), + ("sqcap", "\u{2293}"), + ("sqcaps", "\u{2293}\u{FE00}"), + ("sqcup", "\u{2294}"), + ("sqcups", "\u{2294}\u{FE00}"), + ("sqsub", "\u{228F}"), + ("sqsube", "\u{2291}"), + ("sqsubset", "\u{228F}"), + ("sqsubseteq", "\u{2291}"), + ("sqsup", "\u{2290}"), + ("sqsupe", "\u{2292}"), + ("sqsupset", "\u{2290}"), + ("sqsupseteq", "\u{2292}"), + ("squ", "\u{25A1}"), + ("square", "\u{25A1}"), + ("squarf", "\u{25AA}"), + ("squf", "\u{25AA}"), + ("srarr", "\u{2192}"), + ("sscr", "\u{1D4C8}"), + ("ssetmn", "\u{2216}"), + ("ssmile", "\u{2323}"), + ("sstarf", "\u{22C6}"), + ("star", "\u{2606}"), + ("starf", "\u{2605}"), + ("straightepsilon", "\u{03F5}"), + ("straightphi", "\u{03D5}"), + ("strns", "\u{00AF}"), + ("sub", "\u{2282}"), + ("subE", "\u{2AC5}"), + ("subdot", "\u{2ABD}"), + ("sube", "\u{2286}"), + ("subedot", "\u{2AC3}"), + ("submult", "\u{2AC1}"), + ("subnE", "\u{2ACB}"), + ("subne", "\u{228A}"), + ("subplus", "\u{2ABF}"), + ("subrarr", "\u{2979}"), + ("subset", "\u{2282}"), + ("subseteq", "\u{2286}"), + ("subseteqq", "\u{2AC5}"), + ("subsetneq", "\u{228A}"), + ("subsetneqq", "\u{2ACB}"), + ("subsim", "\u{2AC7}"), + ("subsub", "\u{2AD5}"), + ("subsup", "\u{2AD3}"), + ("succ", "\u{227B}"), + ("succapprox", "\u{2AB8}"), + ("succcurlyeq", "\u{227D}"), + ("succeq", "\u{2AB0}"), + ("succnapprox", "\u{2ABA}"), + ("succneqq", "\u{2AB6}"), + ("succnsim", "\u{22E9}"), + ("succsim", "\u{227F}"), + ("sum", "\u{2211}"), + ("sung", "\u{266A}"), + ("sup", "\u{2283}"), + ("sup1", "\u{00B9}"), + ("sup2", "\u{00B2}"), + ("sup3", "\u{00B3}"), + ("supE", "\u{2AC6}"), + ("supdot", "\u{2ABE}"), + ("supdsub", "\u{2AD8}"), + ("supe", "\u{2287}"), + ("supedot", "\u{2AC4}"), + ("suphsol", "\u{27C9}"), + ("suphsub", "\u{2AD7}"), + ("suplarr", "\u{297B}"), + ("supmult", "\u{2AC2}"), + ("supnE", "\u{2ACC}"), + ("supne", "\u{228B}"), + ("supplus", "\u{2AC0}"), + ("supset", "\u{2283}"), + ("supseteq", "\u{2287}"), + ("supseteqq", "\u{2AC6}"), + ("supsetneq", "\u{228B}"), + ("supsetneqq", "\u{2ACC}"), + ("supsim", "\u{2AC8}"), + ("supsub", "\u{2AD4}"), + ("supsup", "\u{2AD6}"), + ("swArr", "\u{21D9}"), + ("swarhk", "\u{2926}"), + ("swarr", "\u{2199}"), + ("swarrow", "\u{2199}"), + ("swnwar", "\u{292A}"), + ("szlig", "\u{00DF}"), + ("target", "\u{2316}"), + ("tau", "\u{03C4}"), + ("tbrk", "\u{23B4}"), + ("tcaron", "\u{0165}"), + ("tcedil", "\u{0163}"), + ("tcy", "\u{0442}"), + ("tdot", "\u{20DB}"), + ("telrec", "\u{2315}"), + ("tfr", "\u{1D531}"), + ("there4", "\u{2234}"), + ("therefore", "\u{2234}"), + ("theta", "\u{03B8}"), + ("thetasym", "\u{03D1}"), + ("thetav", "\u{03D1}"), + ("thickapprox", "\u{2248}"), + ("thicksim", "\u{223C}"), + ("thinsp", "\u{2009}"), + ("thkap", "\u{2248}"), + ("thksim", "\u{223C}"), + ("thorn", "\u{00FE}"), + ("tilde", "\u{02DC}"), + ("times", "\u{00D7}"), + ("timesb", "\u{22A0}"), + ("timesbar", "\u{2A31}"), + ("timesd", "\u{2A30}"), + ("tint", "\u{222D}"), + ("toea", "\u{2928}"), + ("top", "\u{22A4}"), + ("topbot", "\u{2336}"), + ("topcir", "\u{2AF1}"), + ("topf", "\u{1D565}"), + ("topfork", "\u{2ADA}"), + ("tosa", "\u{2929}"), + ("tprime", "\u{2034}"), + ("trade", "\u{2122}"), + ("triangle", "\u{25B5}"), + ("triangledown", "\u{25BF}"), + ("triangleleft", "\u{25C3}"), + ("trianglelefteq", "\u{22B4}"), + ("triangleq", "\u{225C}"), + ("triangleright", "\u{25B9}"), + ("trianglerighteq", "\u{22B5}"), + ("tridot", "\u{25EC}"), + ("trie", "\u{225C}"), + ("triminus", "\u{2A3A}"), + ("triplus", "\u{2A39}"), + ("trisb", "\u{29CD}"), + ("tritime", "\u{2A3B}"), + ("trpezium", "\u{23E2}"), + ("tscr", "\u{1D4C9}"), + ("tscy", "\u{0446}"), + ("tshcy", "\u{045B}"), + ("tstrok", "\u{0167}"), + ("twixt", "\u{226C}"), + ("twoheadleftarrow", "\u{219E}"), + ("twoheadrightarrow", "\u{21A0}"), + ("uArr", "\u{21D1}"), + ("uHar", "\u{2963}"), + ("uacute", "\u{00FA}"), + ("uarr", "\u{2191}"), + ("ubrcy", "\u{045E}"), + ("ubreve", "\u{016D}"), + ("ucirc", "\u{00FB}"), + ("ucy", "\u{0443}"), + ("udarr", "\u{21C5}"), + ("udblac", "\u{0171}"), + ("udhar", "\u{296E}"), + ("ufisht", "\u{297E}"), + ("ufr", "\u{1D532}"), + ("ugrave", "\u{00F9}"), + ("uharl", "\u{21BF}"), + ("uharr", "\u{21BE}"), + ("uhblk", "\u{2580}"), + ("ulcorn", "\u{231C}"), + ("ulcorner", "\u{231C}"), + ("ulcrop", "\u{230F}"), + ("ultri", "\u{25F8}"), + ("umacr", "\u{016B}"), + ("uml", "\u{00A8}"), + ("uogon", "\u{0173}"), + ("uopf", "\u{1D566}"), + ("uparrow", "\u{2191}"), + ("updownarrow", "\u{2195}"), + ("upharpoonleft", "\u{21BF}"), + ("upharpoonright", "\u{21BE}"), + ("uplus", "\u{228E}"), + ("upsi", "\u{03C5}"), + ("upsih", "\u{03D2}"), + ("upsilon", "\u{03C5}"), + ("upuparrows", "\u{21C8}"), + ("urcorn", "\u{231D}"), + ("urcorner", "\u{231D}"), + ("urcrop", "\u{230E}"), + ("uring", "\u{016F}"), + ("urtri", "\u{25F9}"), + ("uscr", "\u{1D4CA}"), + ("utdot", "\u{22F0}"), + ("utilde", "\u{0169}"), + ("utri", "\u{25B5}"), + ("utrif", "\u{25B4}"), + ("uuarr", "\u{21C8}"), + ("uuml", "\u{00FC}"), + ("uwangle", "\u{29A7}"), + ("vArr", "\u{21D5}"), + ("vBar", "\u{2AE8}"), + ("vBarv", "\u{2AE9}"), + ("vDash", "\u{22A8}"), + ("vangrt", "\u{299C}"), + ("varepsilon", "\u{03F5}"), + ("varkappa", "\u{03F0}"), + ("varnothing", "\u{2205}"), + ("varphi", "\u{03D5}"), + ("varpi", "\u{03D6}"), + ("varpropto", "\u{221D}"), + ("varr", "\u{2195}"), + ("varrho", "\u{03F1}"), + ("varsigma", "\u{03C2}"), + ("varsubsetneq", "\u{228A}\u{FE00}"), + ("varsubsetneqq", "\u{2ACB}\u{FE00}"), + ("varsupsetneq", "\u{228B}\u{FE00}"), + ("varsupsetneqq", "\u{2ACC}\u{FE00}"), + ("vartheta", "\u{03D1}"), + ("vartriangleleft", "\u{22B2}"), + ("vartriangleright", "\u{22B3}"), + ("vcy", "\u{0432}"), + ("vdash", "\u{22A2}"), + ("vee", "\u{2228}"), + ("veebar", "\u{22BB}"), + ("veeeq", "\u{225A}"), + ("vellip", "\u{22EE}"), + ("verbar", "\u{007C}"), + ("vert", "\u{007C}"), + ("vfr", "\u{1D533}"), + ("vltri", "\u{22B2}"), + ("vnsub", "\u{2282}\u{20D2}"), + ("vnsup", "\u{2283}\u{20D2}"), + ("vopf", "\u{1D567}"), + ("vprop", "\u{221D}"), + ("vrtri", "\u{22B3}"), + ("vscr", "\u{1D4CB}"), + ("vsubnE", "\u{2ACB}\u{FE00}"), + ("vsubne", "\u{228A}\u{FE00}"), + ("vsupnE", "\u{2ACC}\u{FE00}"), + ("vsupne", "\u{228B}\u{FE00}"), + ("vzigzag", "\u{299A}"), + ("wcirc", "\u{0175}"), + ("wedbar", "\u{2A5F}"), + ("wedge", "\u{2227}"), + ("wedgeq", "\u{2259}"), + ("weierp", "\u{2118}"), + ("wfr", "\u{1D534}"), + ("wopf", "\u{1D568}"), + ("wp", "\u{2118}"), + ("wr", "\u{2240}"), + ("wreath", "\u{2240}"), + ("wscr", "\u{1D4CC}"), + ("xcap", "\u{22C2}"), + ("xcirc", "\u{25EF}"), + ("xcup", "\u{22C3}"), + ("xdtri", "\u{25BD}"), + ("xfr", "\u{1D535}"), + ("xhArr", "\u{27FA}"), + ("xharr", "\u{27F7}"), + ("xi", "\u{03BE}"), + ("xlArr", "\u{27F8}"), + ("xlarr", "\u{27F5}"), + ("xmap", "\u{27FC}"), + ("xnis", "\u{22FB}"), + ("xodot", "\u{2A00}"), + ("xopf", "\u{1D569}"), + ("xoplus", "\u{2A01}"), + ("xotime", "\u{2A02}"), + ("xrArr", "\u{27F9}"), + ("xrarr", "\u{27F6}"), + ("xscr", "\u{1D4CD}"), + ("xsqcup", "\u{2A06}"), + ("xuplus", "\u{2A04}"), + ("xutri", "\u{25B3}"), + ("xvee", "\u{22C1}"), + ("xwedge", "\u{22C0}"), + ("yacute", "\u{00FD}"), + ("yacy", "\u{044F}"), + ("ycirc", "\u{0177}"), + ("ycy", "\u{044B}"), + ("yen", "\u{00A5}"), + ("yfr", "\u{1D536}"), + ("yicy", "\u{0457}"), + ("yopf", "\u{1D56A}"), + ("yscr", "\u{1D4CE}"), + ("yucy", "\u{044E}"), + ("yuml", "\u{00FF}"), + ("zacute", "\u{017A}"), + ("zcaron", "\u{017E}"), + ("zcy", "\u{0437}"), + ("zdot", "\u{017C}"), + ("zeetrf", "\u{2128}"), + ("zeta", "\u{03B6}"), + ("zfr", "\u{1D537}"), + ("zhcy", "\u{0436}"), + ("zigrarr", "\u{21DD}"), + ("zopf", "\u{1D56B}"), + ("zscr", "\u{1D4CF}"), + ("zwj", "\u{200D}"), + ("zwnj", "\u{200C}"), +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lookup_basic_xml_entities() { + assert_eq!(lookup_entity("amp"), Some("&")); + assert_eq!(lookup_entity("lt"), Some("<")); + assert_eq!(lookup_entity("gt"), Some(">")); + assert_eq!(lookup_entity("apos"), Some("'")); + assert_eq!(lookup_entity("quot"), Some("\"")); + } + + #[test] + fn test_lookup_html4_entities() { + assert_eq!(lookup_entity("nbsp"), Some("\u{00A0}")); + assert_eq!(lookup_entity("copy"), Some("\u{00A9}")); + assert_eq!(lookup_entity("reg"), Some("\u{00AE}")); + assert_eq!(lookup_entity("euro"), Some("\u{20AC}")); + assert_eq!(lookup_entity("mdash"), Some("\u{2014}")); + assert_eq!(lookup_entity("ndash"), Some("\u{2013}")); + assert_eq!(lookup_entity("hellip"), Some("\u{2026}")); + } + + #[test] + fn test_lookup_html5_new_entities() { + // Entities added in HTML5 that were not in HTML 4.01 + assert_eq!(lookup_entity("checkmark"), Some("\u{2713}")); + assert_eq!(lookup_entity("bigstar"), Some("\u{2605}")); + assert_eq!(lookup_entity("pitchfork"), Some("\u{22D4}")); + assert_eq!(lookup_entity("triangledown"), Some("\u{25BF}")); + assert_eq!(lookup_entity("lessgtr"), Some("\u{2276}")); + } + + #[test] + fn test_lookup_multi_codepoint_entities() { + // Entities that expand to multiple Unicode code points + assert_eq!(lookup_entity("NotEqualTilde"), Some("\u{2242}\u{0338}")); + assert_eq!(lookup_entity("nGt"), Some("\u{226B}\u{20D2}")); + assert_eq!(lookup_entity("nLt"), Some("\u{226A}\u{20D2}")); + } + + #[test] + fn test_lookup_greek_entities() { + assert_eq!(lookup_entity("Alpha"), Some("\u{0391}")); + assert_eq!(lookup_entity("alpha"), Some("\u{03B1}")); + assert_eq!(lookup_entity("Omega"), Some("\u{03A9}")); + assert_eq!(lookup_entity("omega"), Some("\u{03C9}")); + assert_eq!(lookup_entity("pi"), Some("\u{03C0}")); + } + + #[test] + fn test_reverse_lookup() { + // For codepoints with multiple entity names, the first alphabetically wins + assert_eq!(reverse_lookup_entity('\u{00A9}'), Some("COPY")); + assert_eq!(reverse_lookup_entity('\u{00A0}'), Some("NonBreakingSpace")); + // These codepoints have unique entity names + assert_eq!(reverse_lookup_entity('\u{0161}'), Some("scaron")); + assert_eq!(reverse_lookup_entity('\u{00E8}'), Some("egrave")); + assert_eq!(reverse_lookup_entity('\u{20AC}'), Some("euro")); + // ASCII characters should not have reverse lookups (handled separately) + assert_eq!(reverse_lookup_entity('A'), None); + assert_eq!(reverse_lookup_entity(' '), None); + } + + #[test] + fn test_lookup_nonexistent() { + assert_eq!(lookup_entity("nonexistent"), None); + assert_eq!(lookup_entity(""), None); + assert_eq!(lookup_entity("NBSP"), None); // case-sensitive + } + + #[test] + fn test_table_is_sorted() { + for window in ENTITIES.windows(2) { + assert!( + window[0].0 < window[1].0, + "entity table not sorted: {:?} should come before {:?}", + window[0].0, + window[1].0 + ); + } + } + + #[test] + fn test_entity_count() { + assert_eq!(ENTITIES.len(), 2125); + } +} diff --git a/browser/vendor/xmloxide/src/html5/mod.rs b/browser/vendor/xmloxide/src/html5/mod.rs new file mode 100644 index 000000000..6dc506b10 --- /dev/null +++ b/browser/vendor/xmloxide/src/html5/mod.rs @@ -0,0 +1,84 @@ +//! WHATWG HTML5 parser. +//! +//! This module implements the [WHATWG HTML Living Standard] parsing algorithm, +//! including tokenization (§13.2.5), tree construction (§13.2.6), and the full +//! set of named character references (§13.5). +//! +//! The parser produces the same [`Document`](crate::tree::Document) tree +//! structure as the XML and HTML 4.01 parsers, using arena-allocated nodes. +//! +//! # Conformance +//! +//! - **Tokenizer:** 7032/7032 html5lib-tests passing (100%) +//! - **Tree construction:** 1778/1778 html5lib-tests passing (100%) +//! +//! # Quick start +//! +//! ``` +//! use xmloxide::html5::parse_html5; +//! +//! let doc = parse_html5("<p>Hello <b>world</b>").unwrap(); +//! let root = doc.root_element().unwrap(); +//! assert_eq!(doc.node_name(root), Some("html")); +//! ``` +//! +//! # Fragment parsing +//! +//! Fragment parsing (the algorithm behind `innerHTML`) is supported via +//! [`Html5ParseOptions::fragment_context`]: +//! +//! ``` +//! use xmloxide::html5::{parse_html5_with_options, Html5ParseOptions}; +//! +//! let opts = Html5ParseOptions { +//! scripting: false, +//! fragment_context: Some("body".to_string()), +//! }; +//! let doc = parse_html5_with_options("<p>fragment</p>", &opts).unwrap(); +//! ``` +//! +//! # Error reporting +//! +//! Use [`parse_html5_full`] to get the document tree together with all parse +//! errors (as [`ParseDiagnostic`](crate::error::ParseDiagnostic)s): +//! +//! ``` +//! use xmloxide::html5::parse_html5_full; +//! +//! let result = parse_html5_full("<p>text"); +//! println!("errors: {}", result.errors.len()); +//! let _doc = result.document; +//! ``` +//! +//! # Streaming (SAX-like) API +//! +//! For large documents where building a full DOM tree is unnecessary, the +//! [`sax`] submodule provides a callback-driven API that wraps the tokenizer +//! directly: +//! +//! ``` +//! use xmloxide::html5::sax::{Html5SaxHandler, parse_html5_sax}; +//! +//! struct Counter { elements: usize } +//! impl Html5SaxHandler for Counter { +//! fn start_element(&mut self, _name: &str, _attrs: &[(String, String)], _sc: bool) { +//! self.elements += 1; +//! } +//! } +//! +//! let mut h = Counter { elements: 0 }; +//! parse_html5_sax("<div><p>Hello</p></div>", &mut h); +//! assert_eq!(h.elements, 2); +//! ``` +//! +//! [WHATWG HTML Living Standard]: https://html.spec.whatwg.org/ + +pub mod entities; +pub mod sax; +pub mod tokenizer; +pub(crate) mod tree_builder; + +pub use tree_builder::{ + parse_html5, parse_html5_full, parse_html5_full_with_options, parse_html5_with_options, + Html5ParseOptions, Html5ParseResult, +}; diff --git a/browser/vendor/xmloxide/src/html5/sax.rs b/browser/vendor/xmloxide/src/html5/sax.rs new file mode 100644 index 000000000..f7789b12b --- /dev/null +++ b/browser/vendor/xmloxide/src/html5/sax.rs @@ -0,0 +1,399 @@ +//! Streaming SAX-like API for HTML5 parsing. +//! +//! Wraps the WHATWG HTML5 tokenizer to fire callbacks for each token +//! without building a DOM tree in memory. This is useful for large HTML +//! documents where you only need to extract specific data. +//! +//! # Examples +//! +//! ``` +//! use xmloxide::html5::sax::{Html5SaxHandler, parse_html5_sax}; +//! +//! struct Counter { elements: usize } +//! +//! impl Html5SaxHandler for Counter { +//! fn start_element( +//! &mut self, +//! name: &str, +//! attributes: &[(String, String)], +//! self_closing: bool, +//! ) { +//! self.elements += 1; +//! } +//! } +//! +//! let mut handler = Counter { elements: 0 }; +//! parse_html5_sax("<div><p>Hello</p></div>", &mut handler); +//! assert_eq!(handler.elements, 2); +//! ``` + +use super::tokenizer::{Token, Tokenizer, TokenizerError}; + +/// An event handler for streaming HTML5 parsing. +/// +/// Implement the callbacks you care about; all methods have default no-op +/// implementations so you only need to override what you need. +/// +/// # Attribute tuples +/// +/// Attributes are passed as `(name, value)` tuples matching the HTML5 +/// tokenizer's attribute representation. +#[allow(unused_variables)] +pub trait Html5SaxHandler { + /// Called when a start tag is encountered. + /// + /// `attributes` contains `(name, value)` tuples. + /// `self_closing` is true for self-closing tags like `<br/>`. + fn start_element(&mut self, name: &str, attributes: &[(String, String)], self_closing: bool) {} + + /// Called when an end tag is encountered. + fn end_element(&mut self, name: &str) {} + + /// Called for character data (text content). + /// + /// Note: the HTML5 tokenizer emits one character at a time; this API + /// coalesces consecutive characters into a single callback for efficiency. + fn characters(&mut self, content: &str) {} + + /// Called for HTML comments. + fn comment(&mut self, content: &str) {} + + /// Called for DOCTYPE declarations. + fn doctype(&mut self, name: Option<&str>, public_id: Option<&str>, system_id: Option<&str>) {} + + /// Called when a tokenizer error is encountered. + fn error(&mut self, error: &TokenizerError) {} +} + +/// A default no-op HTML5 SAX handler. Useful as a base or for testing. +pub struct DefaultHtml5Handler; + +impl Html5SaxHandler for DefaultHtml5Handler {} + +/// Parse HTML5 from a string, firing SAX events on the provided handler. +/// +/// This drives the WHATWG HTML5 tokenizer and calls the appropriate handler +/// methods for each token. No DOM tree is built. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::html5::sax::{Html5SaxHandler, parse_html5_sax}; +/// +/// struct Links { hrefs: Vec<String> } +/// +/// impl Html5SaxHandler for Links { +/// fn start_element( +/// &mut self, +/// name: &str, +/// attributes: &[(String, String)], +/// self_closing: bool, +/// ) { +/// if name == "a" { +/// if let Some((_, href)) = attributes.iter().find(|(n, _)| n == "href") { +/// self.hrefs.push(href.clone()); +/// } +/// } +/// } +/// } +/// +/// let mut handler = Links { hrefs: Vec::new() }; +/// parse_html5_sax( +/// r#"<a href="https://example.com">Link</a>"#, +/// &mut handler, +/// ); +/// assert_eq!(handler.hrefs, vec!["https://example.com"]); +/// ``` +pub fn parse_html5_sax(input: &str, handler: &mut dyn Html5SaxHandler) { + let mut tokenizer = Tokenizer::new(input); + let mut char_buf = String::new(); + + loop { + let token = tokenizer.next_token(); + match token { + Token::Character(c) => { + char_buf.push(c); + continue; + } + _ => { + // Flush any accumulated characters before handling the + // non-character token. + if !char_buf.is_empty() { + handler.characters(&char_buf); + char_buf.clear(); + } + } + } + + match token { + Token::StartTag { + ref name, + ref attributes, + self_closing, + } => { + let attrs: Vec<(String, String)> = attributes + .iter() + .map(|a| (a.name.clone(), a.value.clone())) + .collect(); + handler.start_element(name, &attrs, self_closing); + } + Token::EndTag { ref name } => { + handler.end_element(name); + } + Token::Comment(ref text) => { + handler.comment(text); + } + Token::Doctype { + ref name, + ref public_id, + ref system_id, + .. + } => { + handler.doctype(name.as_deref(), public_id.as_deref(), system_id.as_deref()); + } + Token::Eof => break, + Token::Character(_) => unreachable!(), + } + } + + // Report any tokenizer errors + for error in tokenizer.errors() { + handler.error(error); + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn test_start_and_end_elements() { + struct Recorder { + events: Vec<String>, + } + impl Html5SaxHandler for Recorder { + fn start_element( + &mut self, + name: &str, + _attributes: &[(String, String)], + _self_closing: bool, + ) { + self.events.push(format!("start:{name}")); + } + fn end_element(&mut self, name: &str) { + self.events.push(format!("end:{name}")); + } + } + + let mut handler = Recorder { events: Vec::new() }; + parse_html5_sax("<div><p>text</p></div>", &mut handler); + assert_eq!( + handler.events, + vec!["start:div", "start:p", "end:p", "end:div"] + ); + } + + #[test] + fn test_characters_coalesced() { + struct TextCollector { + texts: Vec<String>, + } + impl Html5SaxHandler for TextCollector { + fn characters(&mut self, content: &str) { + self.texts.push(content.to_string()); + } + } + + let mut handler = TextCollector { texts: Vec::new() }; + parse_html5_sax("<p>Hello World</p>", &mut handler); + // Should be a single coalesced text event, not one per character + assert_eq!(handler.texts.len(), 1); + assert_eq!(handler.texts[0], "Hello World"); + } + + #[test] + fn test_attributes() { + struct AttrCollector { + attrs: Vec<Vec<(String, String)>>, + } + impl Html5SaxHandler for AttrCollector { + fn start_element( + &mut self, + _name: &str, + attributes: &[(String, String)], + _self_closing: bool, + ) { + self.attrs.push(attributes.to_vec()); + } + } + + let mut handler = AttrCollector { attrs: Vec::new() }; + parse_html5_sax( + r#"<a href="http://example.com" class="link">x</a>"#, + &mut handler, + ); + assert_eq!(handler.attrs.len(), 1); + assert_eq!(handler.attrs[0].len(), 2); + assert_eq!(handler.attrs[0][0].0, "href"); + assert_eq!(handler.attrs[0][0].1, "http://example.com"); + assert_eq!(handler.attrs[0][1].0, "class"); + assert_eq!(handler.attrs[0][1].1, "link"); + } + + #[test] + fn test_comment() { + struct CommentCollector { + comments: Vec<String>, + } + impl Html5SaxHandler for CommentCollector { + fn comment(&mut self, content: &str) { + self.comments.push(content.to_string()); + } + } + + let mut handler = CommentCollector { + comments: Vec::new(), + }; + parse_html5_sax("<!-- hello --><p>text</p>", &mut handler); + assert_eq!(handler.comments, vec![" hello "]); + } + + #[test] + fn test_doctype() { + struct DoctypeCollector { + name: Option<String>, + } + impl Html5SaxHandler for DoctypeCollector { + fn doctype( + &mut self, + name: Option<&str>, + _public_id: Option<&str>, + _system_id: Option<&str>, + ) { + self.name = name.map(String::from); + } + } + + let mut handler = DoctypeCollector { name: None }; + parse_html5_sax("<!DOCTYPE html><html></html>", &mut handler); + assert_eq!(handler.name, Some("html".to_string())); + } + + #[test] + fn test_self_closing() { + struct SelfClosingChecker { + self_closing_tags: Vec<String>, + } + impl Html5SaxHandler for SelfClosingChecker { + fn start_element( + &mut self, + name: &str, + _attributes: &[(String, String)], + self_closing: bool, + ) { + if self_closing { + self.self_closing_tags.push(name.to_string()); + } + } + } + + let mut handler = SelfClosingChecker { + self_closing_tags: Vec::new(), + }; + parse_html5_sax("<br/><img/><p>text</p>", &mut handler); + assert_eq!(handler.self_closing_tags, vec!["br", "img"]); + } + + #[test] + fn test_default_handler() { + let mut handler = DefaultHtml5Handler; + parse_html5_sax("<p>test</p>", &mut handler); + // Should not panic — all callbacks are no-ops + } + + #[test] + fn test_error_reporting() { + struct ErrorCounter { + count: usize, + } + impl Html5SaxHandler for ErrorCounter { + fn error(&mut self, _error: &TokenizerError) { + self.count += 1; + } + } + + let mut handler = ErrorCounter { count: 0 }; + // EOF inside a tag triggers eof-in-tag error + parse_html5_sax("<p><div attr=", &mut handler); + assert!(handler.count >= 1); + } + + #[test] + fn test_element_counter() { + struct Counter { + elements: usize, + } + impl Html5SaxHandler for Counter { + fn start_element( + &mut self, + _name: &str, + _attributes: &[(String, String)], + _self_closing: bool, + ) { + self.elements += 1; + } + } + + let mut handler = Counter { elements: 0 }; + parse_html5_sax( + "<html><head><title>Test</title></head><body><p>Hello</p></body></html>", + &mut handler, + ); + assert_eq!(handler.elements, 5); // html, head, title, body, p + } + + #[test] + fn test_multiple_text_segments() { + struct TextCollector { + texts: Vec<String>, + } + impl Html5SaxHandler for TextCollector { + fn characters(&mut self, content: &str) { + self.texts.push(content.to_string()); + } + } + + let mut handler = TextCollector { texts: Vec::new() }; + parse_html5_sax("<p>Hello</p><p>World</p>", &mut handler); + assert_eq!(handler.texts, vec!["Hello", "World"]); + } + + #[test] + fn test_link_extractor() { + struct LinkExtractor { + hrefs: Vec<String>, + } + impl Html5SaxHandler for LinkExtractor { + fn start_element( + &mut self, + name: &str, + attributes: &[(String, String)], + _self_closing: bool, + ) { + if name == "a" { + if let Some((_, href)) = attributes.iter().find(|(n, _)| n == "href") { + self.hrefs.push(href.clone()); + } + } + } + } + + let mut handler = LinkExtractor { hrefs: Vec::new() }; + parse_html5_sax( + r#"<a href="/one">1</a><a href="/two">2</a><span>no link</span>"#, + &mut handler, + ); + assert_eq!(handler.hrefs, vec!["/one", "/two"]); + } +} diff --git a/browser/vendor/xmloxide/src/html5/tokenizer.rs b/browser/vendor/xmloxide/src/html5/tokenizer.rs new file mode 100644 index 000000000..0002d5166 --- /dev/null +++ b/browser/vendor/xmloxide/src/html5/tokenizer.rs @@ -0,0 +1,3374 @@ +//! WHATWG HTML5 tokenizer state machine. +//! +//! This module implements the tokenization stage of the HTML parsing algorithm +//! as defined in the WHATWG HTML Living Standard. +//! +//! See <https://html.spec.whatwg.org/multipage/parsing.html#tokenization> + +use std::borrow::Cow; +use std::collections::VecDeque; + +use crate::html5::entities::lookup_entity; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/// A single token produced by the HTML5 tokenizer. +#[derive(Debug, Clone, PartialEq)] +pub enum Token { + /// A DOCTYPE token. + Doctype { + /// The DOCTYPE name (e.g. `html`). + name: Option<String>, + /// The public identifier, if any. + public_id: Option<String>, + /// The system identifier, if any. + system_id: Option<String>, + /// Whether the force-quirks flag is set. + force_quirks: bool, + }, + /// A start tag token. + StartTag { + /// The tag name. + name: String, + /// The list of attributes. + attributes: Vec<Attribute>, + /// Whether the self-closing flag is set. + self_closing: bool, + }, + /// An end tag token. + EndTag { + /// The tag name. + name: String, + }, + /// A single character token. + Character(char), + /// A comment token. + Comment(String), + /// End-of-file token. + Eof, +} + +/// An attribute on a start tag token. +#[derive(Debug, Clone, PartialEq)] +pub struct Attribute { + /// The attribute name. + pub name: String, + /// The attribute value. + pub value: String, +} + +/// A tokenizer error with a WHATWG-specified error code and byte position. +#[derive(Debug, Clone, PartialEq)] +pub struct TokenizerError { + /// The WHATWG error code string (e.g. `"eof-in-doctype"`). + pub code: &'static str, + /// Byte offset in the input where the error occurred. + pub span: usize, +} + +// --------------------------------------------------------------------------- +// Tokenizer states +// --------------------------------------------------------------------------- + +/// All states of the WHATWG HTML tokenizer state machine. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(clippy::doc_markdown, dead_code)] +pub enum State { + Data, + RcData, + RawText, + ScriptData, + Plaintext, + TagOpen, + EndTagOpen, + TagName, + RcDataLessThanSign, + RcDataEndTagOpen, + RcDataEndTagName, + RawTextLessThanSign, + RawTextEndTagOpen, + RawTextEndTagName, + ScriptDataLessThanSign, + ScriptDataEndTagOpen, + ScriptDataEndTagName, + ScriptDataEscapeStart, + ScriptDataEscapeStartDash, + ScriptDataEscaped, + ScriptDataEscapedDash, + ScriptDataEscapedDashDash, + ScriptDataEscapedLessThanSign, + ScriptDataEscapedEndTagOpen, + ScriptDataEscapedEndTagName, + ScriptDataDoubleEscapeStart, + ScriptDataDoubleEscaped, + ScriptDataDoubleEscapedDash, + ScriptDataDoubleEscapedDashDash, + ScriptDataDoubleEscapedLessThanSign, + ScriptDataDoubleEscapeEnd, + BeforeAttributeName, + AttributeName, + AfterAttributeName, + BeforeAttributeValue, + AttributeValueDoubleQuoted, + AttributeValueSingleQuoted, + AttributeValueUnquoted, + AfterAttributeValueQuoted, + SelfClosingStartTag, + BogusComment, + MarkupDeclarationOpen, + CommentStart, + CommentStartDash, + Comment, + CommentLessThanSign, + CommentLessThanSignBang, + CommentLessThanSignBangDash, + CommentLessThanSignBangDashDash, + CommentEndDash, + CommentEnd, + CommentEndBang, + Doctype, + BeforeDoctypeName, + DoctypeName, + AfterDoctypeName, + AfterDoctypePublicKeyword, + BeforeDoctypePublicIdentifier, + DoctypePublicIdentifierDoubleQuoted, + DoctypePublicIdentifierSingleQuoted, + AfterDoctypePublicIdentifier, + BetweenDoctypePublicAndSystemIdentifiers, + AfterDoctypeSystemKeyword, + BeforeDoctypeSystemIdentifier, + DoctypeSystemIdentifierDoubleQuoted, + DoctypeSystemIdentifierSingleQuoted, + AfterDoctypeSystemIdentifier, + BogusDoctype, + CdataSection, + CdataSectionBracket, + CdataSectionEnd, + CharacterReference, + NamedCharacterReference, + AmbiguousAmpersand, + NumericCharacterReference, + HexadecimalCharacterReferenceStart, + DecimalCharacterReferenceStart, + HexadecimalCharacterReference, + DecimalCharacterReference, + NumericCharacterReferenceEnd, +} + +// --------------------------------------------------------------------------- +// Tokenizer +// --------------------------------------------------------------------------- + +/// The WHATWG HTML5 tokenizer. +/// +/// Converts an input string into a sequence of [`Token`] values by walking +/// through the state machine described in the WHATWG specification. +#[allow(clippy::struct_excessive_bools)] +pub struct Tokenizer<'a> { + input: Cow<'a, str>, + pos: usize, + state: State, + return_state: State, + // Current tag being built + current_tag_name: String, + current_tag_attrs: Vec<Attribute>, + current_tag_self_closing: bool, + current_tag_is_end: bool, + current_attr_name: String, + current_attr_value: String, + // Current comment being built + current_comment: String, + // Current DOCTYPE being built + current_doctype_name: Option<String>, + current_doctype_public_id: Option<String>, + current_doctype_system_id: Option<String>, + current_doctype_force_quirks: bool, + // Temporary buffer (character references, end-tag matching) + temp_buffer: String, + // Output queue – tokens waiting to be returned (FIFO) + pending_tokens: VecDeque<Token>, + // Error tracking + errors: Vec<TokenizerError>, + // Last emitted start tag name (for appropriate end tag checks) + last_start_tag_name: Option<String>, + // Character reference accumulator + char_ref_code: u32, + // Whether the adjusted current node is in a foreign (non-HTML) namespace. + // Set by the tree builder; controls CDATA section handling. + allow_cdata: bool, +} + +impl<'a> Tokenizer<'a> { + /// Creates a new tokenizer for the given input string. + /// + /// The input is preprocessed to normalize newlines per the WHATWG spec: + /// CR (U+000D) and CR+LF pairs are replaced with LF (U+000A). + pub fn new(input: &'a str) -> Self { + let input = if input.contains('\r') { + Cow::Owned(normalize_newlines(input)) + } else { + Cow::Borrowed(input) + }; + Self { + input, + pos: 0, + state: State::Data, + return_state: State::Data, + current_tag_name: String::new(), + current_tag_attrs: Vec::new(), + current_tag_self_closing: false, + current_tag_is_end: false, + current_attr_name: String::new(), + current_attr_value: String::new(), + current_comment: String::new(), + current_doctype_name: None, + current_doctype_public_id: None, + current_doctype_system_id: None, + current_doctype_force_quirks: false, + temp_buffer: String::new(), + pending_tokens: VecDeque::new(), + errors: Vec::new(), + last_start_tag_name: None, + char_ref_code: 0, + allow_cdata: false, + } + } + + /// Returns a reference to the errors collected so far. + pub fn errors(&self) -> &[TokenizerError] { + &self.errors + } + + /// Allows the tree builder to switch the tokenizer state (e.g. to + /// `RcData` or `RawText` when entering `<textarea>` or `<style>`). + pub fn set_state(&mut self, state: State) { + self.state = state; + } + + /// Sets the tokenizer state from a string name (for test harnesses). + /// + /// Accepted names: `"Data"`, `"Plaintext"`, `"RcData"`, `"RawText"`, + /// `"ScriptData"`, `"CDataSection"`. Unknown names are ignored. + pub fn set_state_for_test(&mut self, name: &str) { + let state = match name { + "Data" => State::Data, + "Plaintext" => State::Plaintext, + "RcData" => State::RcData, + "RawText" => State::RawText, + "ScriptData" => State::ScriptData, + "CDataSection" => State::CdataSection, + _ => return, + }; + self.state = state; + } + + /// Sets whether the adjusted current node is in a foreign (non-HTML) + /// namespace. When `true`, the tokenizer will handle `<![CDATA[` as a + /// CDATA section; when `false`, it will be treated as a bogus comment. + pub fn set_allow_cdata(&mut self, allow: bool) { + self.allow_cdata = allow; + } + + /// Allows the tree builder to inform the tokenizer of the last emitted + /// start tag name, so the tokenizer can match appropriate end tags in + /// RCDATA/RAWTEXT/Script data states. + pub fn set_last_start_tag(&mut self, name: &str) { + self.last_start_tag_name = Some(name.to_string()); + } + + /// Returns the next token from the input. + /// + /// Returns [`Token::Eof`] when the input is exhausted. + #[allow(clippy::too_many_lines)] + pub fn next_token(&mut self) -> Token { + // Drain pending queue first (character reference expansions, etc.) + if let Some(tok) = self.pending_tokens.pop_front() { + return tok; + } + loop { + if let Some(tok) = self.pending_tokens.pop_front() { + return tok; + } + match self.state { + State::Data => self.state_data(), + State::RcData => self.state_rcdata(), + State::RawText => self.state_rawtext(), + State::ScriptData => self.state_script_data(), + State::Plaintext => self.state_plaintext(), + State::TagOpen => self.state_tag_open(), + State::EndTagOpen => self.state_end_tag_open(), + State::TagName => self.state_tag_name(), + State::RcDataLessThanSign => self.state_rcdata_less_than_sign(), + State::RcDataEndTagOpen => self.state_rcdata_end_tag_open(), + State::RcDataEndTagName => self.state_rcdata_end_tag_name(), + State::RawTextLessThanSign => self.state_rawtext_less_than_sign(), + State::RawTextEndTagOpen => self.state_rawtext_end_tag_open(), + State::RawTextEndTagName => self.state_rawtext_end_tag_name(), + State::ScriptDataLessThanSign => self.state_script_data_less_than_sign(), + State::ScriptDataEndTagOpen => self.state_script_data_end_tag_open(), + State::ScriptDataEndTagName => self.state_script_data_end_tag_name(), + State::ScriptDataEscapeStart => self.state_script_data_escape_start(), + State::ScriptDataEscapeStartDash => { + self.state_script_data_escape_start_dash(); + } + State::ScriptDataEscaped => self.state_script_data_escaped(), + State::ScriptDataEscapedDash => self.state_script_data_escaped_dash(), + State::ScriptDataEscapedDashDash => { + self.state_script_data_escaped_dash_dash(); + } + State::ScriptDataEscapedLessThanSign => { + self.state_script_data_escaped_less_than_sign(); + } + State::ScriptDataEscapedEndTagOpen => { + self.state_script_data_escaped_end_tag_open(); + } + State::ScriptDataEscapedEndTagName => { + self.state_script_data_escaped_end_tag_name(); + } + State::ScriptDataDoubleEscapeStart => { + self.state_script_data_double_escape_start(); + } + State::ScriptDataDoubleEscaped => { + self.state_script_data_double_escaped(); + } + State::ScriptDataDoubleEscapedDash => { + self.state_script_data_double_escaped_dash(); + } + State::ScriptDataDoubleEscapedDashDash => { + self.state_script_data_double_escaped_dash_dash(); + } + State::ScriptDataDoubleEscapedLessThanSign => { + self.state_script_data_double_escaped_less_than_sign(); + } + State::ScriptDataDoubleEscapeEnd => { + self.state_script_data_double_escape_end(); + } + State::BeforeAttributeName => self.state_before_attribute_name(), + State::AttributeName => self.state_attribute_name(), + State::AfterAttributeName => self.state_after_attribute_name(), + State::BeforeAttributeValue => self.state_before_attribute_value(), + State::AttributeValueDoubleQuoted => { + self.state_attribute_value_double_quoted(); + } + State::AttributeValueSingleQuoted => { + self.state_attribute_value_single_quoted(); + } + State::AttributeValueUnquoted => self.state_attribute_value_unquoted(), + State::AfterAttributeValueQuoted => { + self.state_after_attribute_value_quoted(); + } + State::SelfClosingStartTag => self.state_self_closing_start_tag(), + State::BogusComment => self.state_bogus_comment(), + State::MarkupDeclarationOpen => self.state_markup_declaration_open(), + State::CommentStart => self.state_comment_start(), + State::CommentStartDash => self.state_comment_start_dash(), + State::Comment => self.state_comment(), + State::CommentLessThanSign => self.state_comment_less_than_sign(), + State::CommentLessThanSignBang => { + self.state_comment_less_than_sign_bang(); + } + State::CommentLessThanSignBangDash => { + self.state_comment_less_than_sign_bang_dash(); + } + State::CommentLessThanSignBangDashDash => { + self.state_comment_less_than_sign_bang_dash_dash(); + } + State::CommentEndDash => self.state_comment_end_dash(), + State::CommentEnd => self.state_comment_end(), + State::CommentEndBang => self.state_comment_end_bang(), + State::Doctype => self.state_doctype(), + State::BeforeDoctypeName => self.state_before_doctype_name(), + State::DoctypeName => self.state_doctype_name(), + State::AfterDoctypeName => self.state_after_doctype_name(), + State::AfterDoctypePublicKeyword => { + self.state_after_doctype_public_keyword(); + } + State::BeforeDoctypePublicIdentifier => { + self.state_before_doctype_public_identifier(); + } + State::DoctypePublicIdentifierDoubleQuoted => { + self.state_doctype_public_identifier_double_quoted(); + } + State::DoctypePublicIdentifierSingleQuoted => { + self.state_doctype_public_identifier_single_quoted(); + } + State::AfterDoctypePublicIdentifier => { + self.state_after_doctype_public_identifier(); + } + State::BetweenDoctypePublicAndSystemIdentifiers => { + self.state_between_doctype_public_and_system_identifiers(); + } + State::AfterDoctypeSystemKeyword => { + self.state_after_doctype_system_keyword(); + } + State::BeforeDoctypeSystemIdentifier => { + self.state_before_doctype_system_identifier(); + } + State::DoctypeSystemIdentifierDoubleQuoted => { + self.state_doctype_system_identifier_double_quoted(); + } + State::DoctypeSystemIdentifierSingleQuoted => { + self.state_doctype_system_identifier_single_quoted(); + } + State::AfterDoctypeSystemIdentifier => { + self.state_after_doctype_system_identifier(); + } + State::BogusDoctype => self.state_bogus_doctype(), + State::CdataSection => self.state_cdata_section(), + State::CdataSectionBracket => self.state_cdata_section_bracket(), + State::CdataSectionEnd => self.state_cdata_section_end(), + State::CharacterReference => self.state_character_reference(), + State::NamedCharacterReference => { + self.state_named_character_reference(); + } + State::AmbiguousAmpersand => self.state_ambiguous_ampersand(), + State::NumericCharacterReference => { + self.state_numeric_character_reference(); + } + State::HexadecimalCharacterReferenceStart => { + self.state_hexadecimal_character_reference_start(); + } + State::DecimalCharacterReferenceStart => { + self.state_decimal_character_reference_start(); + } + State::HexadecimalCharacterReference => { + self.state_hexadecimal_character_reference(); + } + State::DecimalCharacterReference => { + self.state_decimal_character_reference(); + } + State::NumericCharacterReferenceEnd => { + self.state_numeric_character_reference_end(); + } + } + } + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /// Peek at the next character without consuming it. + fn peek(&self) -> Option<char> { + self.input[self.pos..].chars().next() + } + + /// Consume and return the next character, advancing `pos`. + fn consume(&mut self) -> Option<char> { + let ch = self.input[self.pos..].chars().next()?; + self.pos += ch.len_utf8(); + Some(ch) + } + + /// Reconsume: back up by the byte-length of the given character. + fn reconsume(&mut self, ch: char) { + self.pos -= ch.len_utf8(); + } + + /// Check if the upcoming input (case-insensitively) matches `needle`, + /// without consuming. `needle` must be ASCII. + fn next_chars_are_ascii_ci(&self, needle: &str) -> bool { + let remaining = self.input.as_bytes(); + if self.pos + needle.len() > remaining.len() { + return false; + } + remaining[self.pos..self.pos + needle.len()].eq_ignore_ascii_case(needle.as_bytes()) + } + + /// Push a parse error. + fn emit_error(&mut self, code: &'static str) { + self.errors.push(TokenizerError { + code, + span: self.pos, + }); + } + + /// Emit a character token (pushes to pending queue). + fn emit_char(&mut self, ch: char) { + self.pending_tokens.push_back(Token::Character(ch)); + } + + /// Emit EOF. + fn emit_eof(&mut self) { + self.pending_tokens.push_back(Token::Eof); + } + + /// Emit the current comment token. + fn emit_comment(&mut self) { + let comment = std::mem::take(&mut self.current_comment); + self.pending_tokens.push_back(Token::Comment(comment)); + } + + /// Emit the current tag token (start or end). + fn emit_current_tag(&mut self) { + self.finish_current_attr(); + if self.current_tag_is_end { + self.pending_tokens.push_back(Token::EndTag { + name: std::mem::take(&mut self.current_tag_name), + }); + } else { + let name = std::mem::take(&mut self.current_tag_name); + self.last_start_tag_name = Some(name.clone()); + self.pending_tokens.push_back(Token::StartTag { + name, + attributes: std::mem::take(&mut self.current_tag_attrs), + self_closing: self.current_tag_self_closing, + }); + } + self.current_tag_self_closing = false; + } + + /// Emit the current DOCTYPE token. + fn emit_doctype(&mut self) { + self.pending_tokens.push_back(Token::Doctype { + name: self.current_doctype_name.take(), + public_id: self.current_doctype_public_id.take(), + system_id: self.current_doctype_system_id.take(), + force_quirks: self.current_doctype_force_quirks, + }); + self.current_doctype_force_quirks = false; + } + + /// Start building a new start-tag token. + fn create_start_tag(&mut self) { + self.current_tag_name.clear(); + self.current_tag_attrs.clear(); + self.current_tag_self_closing = false; + self.current_tag_is_end = false; + self.current_attr_name.clear(); + self.current_attr_value.clear(); + } + + /// Start building a new end-tag token. + fn create_end_tag(&mut self) { + self.current_tag_name.clear(); + self.current_tag_attrs.clear(); + self.current_tag_self_closing = false; + self.current_tag_is_end = true; + self.current_attr_name.clear(); + self.current_attr_value.clear(); + } + + /// Start a new attribute on the current tag. + fn start_new_attr(&mut self) { + self.finish_current_attr(); + self.current_attr_name.clear(); + self.current_attr_value.clear(); + } + + /// Finish the current attribute (push it to the tag's attribute list + /// if the name is non-empty and not a duplicate). + fn finish_current_attr(&mut self) { + if self.current_attr_name.is_empty() { + return; + } + let name = std::mem::take(&mut self.current_attr_name); + let value = std::mem::take(&mut self.current_attr_value); + // The spec says duplicate attributes are parse errors; keep first. + if !self.current_tag_attrs.iter().any(|a| a.name == name) { + self.current_tag_attrs.push(Attribute { name, value }); + } + } + + /// Create a new DOCTYPE token with all fields empty. + fn create_doctype(&mut self) { + self.current_doctype_name = None; + self.current_doctype_public_id = None; + self.current_doctype_system_id = None; + self.current_doctype_force_quirks = false; + } + + /// Check if the current end tag is an appropriate end tag + /// (its name matches the last emitted start tag name). + fn is_appropriate_end_tag(&self) -> bool { + if let Some(ref last) = self.last_start_tag_name { + *last == self.current_tag_name + } else { + false + } + } + + /// Flush code points consumed as a character reference. + /// + /// If the return state is an attribute value state, append `temp_buffer` + /// to the current attribute value; otherwise emit each character. + fn flush_code_points_consumed_as_char_ref(&mut self) { + let buf = std::mem::take(&mut self.temp_buffer); + if is_attr_value_state(self.return_state) { + self.current_attr_value.push_str(&buf); + } else { + // Emit each char individually. + for ch in buf.chars() { + self.pending_tokens.push_back(Token::Character(ch)); + } + } + } + + // ----------------------------------------------------------------------- + // State implementations + // ----------------------------------------------------------------------- + + // 13.2.5.1 Data state + fn state_data(&mut self) { + // Fast path: scan forward through bytes that don't need special + // handling (not '<', '&', or '\0'). This avoids per-character + // overhead for plain text runs. + let bytes = self.input.as_bytes(); + let start = self.pos; + let mut i = start; + while i < bytes.len() { + let b = bytes[i]; + if b == b'<' || b == b'&' || b == 0 { + break; + } + i += 1; + } + if i > start { + // All bytes in start..i are safe plain text (no null, no < or &). + // Because we only break on ASCII bytes and skip non-ASCII bytes, + // start..i is always a valid UTF-8 slice boundary. + for c in self.input[start..i].chars() { + self.pending_tokens.push_back(Token::Character(c)); + } + self.pos = i; + return; + } + + // Slow path: handle special characters one at a time. + match self.consume() { + Some('&') => { + self.return_state = State::Data; + self.state = State::CharacterReference; + } + Some('<') => { + self.state = State::TagOpen; + } + Some('\0') => { + // Per spec: emit the null character as-is (with a parse error). + // Unlike RCDATA/RAWTEXT, Data state does NOT replace with U+FFFD. + self.emit_error("unexpected-null-character"); + self.emit_char('\0'); + } + None => { + self.emit_eof(); + } + Some(c) => { + self.emit_char(c); + } + } + } + + // 13.2.5.2 RCDATA state + fn state_rcdata(&mut self) { + match self.consume() { + Some('&') => { + self.return_state = State::RcData; + self.state = State::CharacterReference; + } + Some('<') => { + self.state = State::RcDataLessThanSign; + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + self.emit_char('\u{FFFD}'); + } + None => { + self.emit_eof(); + } + Some(c) => { + self.emit_char(c); + } + } + } + + // 13.2.5.3 RAWTEXT state + fn state_rawtext(&mut self) { + match self.consume() { + Some('<') => { + self.state = State::RawTextLessThanSign; + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + self.emit_char('\u{FFFD}'); + } + None => { + self.emit_eof(); + } + Some(c) => { + self.emit_char(c); + } + } + } + + // 13.2.5.4 Script data state + fn state_script_data(&mut self) { + match self.consume() { + Some('<') => { + self.state = State::ScriptDataLessThanSign; + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + self.emit_char('\u{FFFD}'); + } + None => { + self.emit_eof(); + } + Some(c) => { + self.emit_char(c); + } + } + } + + // 13.2.5.5 PLAINTEXT state + fn state_plaintext(&mut self) { + match self.consume() { + Some('\0') => { + self.emit_error("unexpected-null-character"); + self.emit_char('\u{FFFD}'); + } + None => { + self.emit_eof(); + } + Some(c) => { + self.emit_char(c); + } + } + } + + // 13.2.5.6 Tag open state + fn state_tag_open(&mut self) { + match self.consume() { + Some('!') => { + self.state = State::MarkupDeclarationOpen; + } + Some('/') => { + self.state = State::EndTagOpen; + } + Some(c) if c.is_ascii_alphabetic() => { + self.create_start_tag(); + self.reconsume(c); + self.state = State::TagName; + } + Some('?') => { + self.emit_error("unexpected-question-mark-instead-of-tag-name"); + self.current_comment.clear(); + self.reconsume('?'); + self.state = State::BogusComment; + } + None => { + self.emit_error("eof-before-tag-name"); + self.emit_char('<'); + self.emit_eof(); + } + Some(c) => { + self.emit_error("invalid-first-character-of-tag-name"); + self.reconsume(c); + self.state = State::Data; + self.emit_char('<'); + } + } + } + + // 13.2.5.7 End tag open state + fn state_end_tag_open(&mut self) { + match self.consume() { + Some(c) if c.is_ascii_alphabetic() => { + self.create_end_tag(); + self.reconsume(c); + self.state = State::TagName; + } + Some('>') => { + self.emit_error("missing-end-tag-name"); + self.state = State::Data; + } + None => { + self.emit_error("eof-before-tag-name"); + self.emit_char('<'); + self.emit_char('/'); + self.emit_eof(); + } + Some(c) => { + self.emit_error("invalid-first-character-of-tag-name"); + self.current_comment.clear(); + self.reconsume(c); + self.state = State::BogusComment; + } + } + } + + // 13.2.5.8 Tag name state + fn state_tag_name(&mut self) { + // Fast path: scan ahead through ASCII lowercase tag-name characters. + let bytes = self.input.as_bytes(); + let start = self.pos; + let mut i = start; + while i < bytes.len() { + let b = bytes[i]; + match b { + b'\t' | b'\n' | 0x0C | b' ' | b'/' | b'>' | 0 | 0x80..=0xFF => break, + b'A'..=b'Z' => { + self.current_tag_name.push((b + 32) as char); + i += 1; + } + _ => { + self.current_tag_name.push(b as char); + i += 1; + } + } + } + self.pos = i; + + // Now handle the terminating character. + match self.consume() { + Some('\t' | '\n' | '\x0C' | ' ') => { + self.state = State::BeforeAttributeName; + } + Some('/') => { + self.state = State::SelfClosingStartTag; + } + Some('>') => { + self.state = State::Data; + self.emit_current_tag(); + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + self.current_tag_name.push('\u{FFFD}'); + } + None => { + self.emit_error("eof-in-tag"); + self.emit_eof(); + } + Some(c) => { + self.current_tag_name.push(c.to_ascii_lowercase()); + } + } + } + + // 13.2.5.9 RCDATA less-than sign state + fn state_rcdata_less_than_sign(&mut self) { + if let Some('/') = self.peek() { + self.consume(); + self.temp_buffer.clear(); + self.state = State::RcDataEndTagOpen; + } else { + self.state = State::RcData; + self.emit_char('<'); + } + } + + // 13.2.5.10 RCDATA end tag open state + fn state_rcdata_end_tag_open(&mut self) { + match self.peek() { + Some(c) if c.is_ascii_alphabetic() => { + self.create_end_tag(); + self.state = State::RcDataEndTagName; + } + _ => { + self.state = State::RcData; + self.emit_char('<'); + self.emit_char('/'); + } + } + } + + // 13.2.5.11 RCDATA end tag name state + fn state_rcdata_end_tag_name(&mut self) { + match self.consume() { + Some(c @ ('\t' | '\n' | '\x0C' | ' ')) => { + if self.is_appropriate_end_tag() { + self.state = State::BeforeAttributeName; + } else { + self.emit_char('<'); + self.emit_char('/'); + self.emit_temp_buffer_chars(); + self.reconsume(c); + self.state = State::RcData; + } + } + Some('/') => { + if self.is_appropriate_end_tag() { + self.state = State::SelfClosingStartTag; + } else { + self.emit_char('<'); + self.emit_char('/'); + self.emit_temp_buffer_chars(); + self.reconsume('/'); + self.state = State::RcData; + } + } + Some('>') => { + if self.is_appropriate_end_tag() { + self.state = State::Data; + self.emit_current_tag(); + } else { + self.emit_char('<'); + self.emit_char('/'); + self.emit_temp_buffer_chars(); + self.reconsume('>'); + self.state = State::RcData; + } + } + Some(c) if c.is_ascii_alphabetic() => { + self.current_tag_name.push(c.to_ascii_lowercase()); + self.temp_buffer.push(c); + } + None => { + self.emit_char('<'); + self.emit_char('/'); + self.emit_temp_buffer_chars(); + self.state = State::RcData; + } + Some(c) => { + self.emit_char('<'); + self.emit_char('/'); + self.emit_temp_buffer_chars(); + self.reconsume(c); + self.state = State::RcData; + } + } + } + + /// Emit each character in `temp_buffer` as a character token. + fn emit_temp_buffer_chars(&mut self) { + let buf = std::mem::take(&mut self.temp_buffer); + for ch in buf.chars() { + self.emit_char(ch); + } + } + + // 13.2.5.12 RAWTEXT less-than sign state + fn state_rawtext_less_than_sign(&mut self) { + if let Some('/') = self.peek() { + self.consume(); + self.temp_buffer.clear(); + self.state = State::RawTextEndTagOpen; + } else { + self.state = State::RawText; + self.emit_char('<'); + } + } + + // 13.2.5.13 RAWTEXT end tag open state + fn state_rawtext_end_tag_open(&mut self) { + match self.peek() { + Some(c) if c.is_ascii_alphabetic() => { + self.create_end_tag(); + self.state = State::RawTextEndTagName; + } + _ => { + self.state = State::RawText; + self.emit_char('<'); + self.emit_char('/'); + } + } + } + + // 13.2.5.14 RAWTEXT end tag name state + fn state_rawtext_end_tag_name(&mut self) { + match self.consume() { + Some(c @ ('\t' | '\n' | '\x0C' | ' ')) => { + if self.is_appropriate_end_tag() { + self.state = State::BeforeAttributeName; + } else { + self.emit_char('<'); + self.emit_char('/'); + self.emit_temp_buffer_chars(); + self.reconsume(c); + self.state = State::RawText; + } + } + Some('/') => { + if self.is_appropriate_end_tag() { + self.state = State::SelfClosingStartTag; + } else { + self.emit_char('<'); + self.emit_char('/'); + self.emit_temp_buffer_chars(); + self.reconsume('/'); + self.state = State::RawText; + } + } + Some('>') => { + if self.is_appropriate_end_tag() { + self.state = State::Data; + self.emit_current_tag(); + } else { + self.emit_char('<'); + self.emit_char('/'); + self.emit_temp_buffer_chars(); + self.reconsume('>'); + self.state = State::RawText; + } + } + Some(c) if c.is_ascii_alphabetic() => { + self.current_tag_name.push(c.to_ascii_lowercase()); + self.temp_buffer.push(c); + } + None => { + self.emit_char('<'); + self.emit_char('/'); + self.emit_temp_buffer_chars(); + self.state = State::RawText; + } + Some(c) => { + self.emit_char('<'); + self.emit_char('/'); + self.emit_temp_buffer_chars(); + self.reconsume(c); + self.state = State::RawText; + } + } + } + + // 13.2.5.15 Script data less-than sign state + fn state_script_data_less_than_sign(&mut self) { + match self.peek() { + Some('/') => { + self.consume(); + self.temp_buffer.clear(); + self.state = State::ScriptDataEndTagOpen; + } + Some('!') => { + self.consume(); + self.state = State::ScriptDataEscapeStart; + self.emit_char('<'); + self.emit_char('!'); + } + _ => { + self.state = State::ScriptData; + self.emit_char('<'); + } + } + } + + // 13.2.5.16 Script data end tag open state + fn state_script_data_end_tag_open(&mut self) { + match self.peek() { + Some(c) if c.is_ascii_alphabetic() => { + self.create_end_tag(); + self.state = State::ScriptDataEndTagName; + } + _ => { + self.state = State::ScriptData; + self.emit_char('<'); + self.emit_char('/'); + } + } + } + + // 13.2.5.17 Script data end tag name state + fn state_script_data_end_tag_name(&mut self) { + match self.consume() { + Some(c @ ('\t' | '\n' | '\x0C' | ' ')) => { + if self.is_appropriate_end_tag() { + self.state = State::BeforeAttributeName; + } else { + self.emit_char('<'); + self.emit_char('/'); + self.emit_temp_buffer_chars(); + self.reconsume(c); + self.state = State::ScriptData; + } + } + Some('/') => { + if self.is_appropriate_end_tag() { + self.state = State::SelfClosingStartTag; + } else { + self.emit_char('<'); + self.emit_char('/'); + self.emit_temp_buffer_chars(); + self.reconsume('/'); + self.state = State::ScriptData; + } + } + Some('>') => { + if self.is_appropriate_end_tag() { + self.state = State::Data; + self.emit_current_tag(); + } else { + self.emit_char('<'); + self.emit_char('/'); + self.emit_temp_buffer_chars(); + self.reconsume('>'); + self.state = State::ScriptData; + } + } + Some(c) if c.is_ascii_alphabetic() => { + self.current_tag_name.push(c.to_ascii_lowercase()); + self.temp_buffer.push(c); + } + None => { + self.emit_char('<'); + self.emit_char('/'); + self.emit_temp_buffer_chars(); + self.state = State::ScriptData; + } + Some(c) => { + self.emit_char('<'); + self.emit_char('/'); + self.emit_temp_buffer_chars(); + self.reconsume(c); + self.state = State::ScriptData; + } + } + } + + // 13.2.5.18 Script data escape start state + fn state_script_data_escape_start(&mut self) { + match self.peek() { + Some('-') => { + self.consume(); + self.state = State::ScriptDataEscapeStartDash; + self.emit_char('-'); + } + _ => { + self.state = State::ScriptData; + } + } + } + + // 13.2.5.19 Script data escape start dash state + fn state_script_data_escape_start_dash(&mut self) { + match self.peek() { + Some('-') => { + self.consume(); + self.state = State::ScriptDataEscapedDashDash; + self.emit_char('-'); + } + _ => { + self.state = State::ScriptData; + } + } + } + + // 13.2.5.20 Script data escaped state + fn state_script_data_escaped(&mut self) { + match self.consume() { + Some('-') => { + self.state = State::ScriptDataEscapedDash; + self.emit_char('-'); + } + Some('<') => { + self.state = State::ScriptDataEscapedLessThanSign; + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + self.emit_char('\u{FFFD}'); + } + None => { + self.emit_error("eof-in-script-html-comment-like-text"); + self.emit_eof(); + } + Some(c) => { + self.emit_char(c); + } + } + } + + // 13.2.5.21 Script data escaped dash state + fn state_script_data_escaped_dash(&mut self) { + match self.consume() { + Some('-') => { + self.state = State::ScriptDataEscapedDashDash; + self.emit_char('-'); + } + Some('<') => { + self.state = State::ScriptDataEscapedLessThanSign; + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + self.state = State::ScriptDataEscaped; + self.emit_char('\u{FFFD}'); + } + None => { + self.emit_error("eof-in-script-html-comment-like-text"); + self.emit_eof(); + } + Some(c) => { + self.state = State::ScriptDataEscaped; + self.emit_char(c); + } + } + } + + // 13.2.5.22 Script data escaped dash dash state + fn state_script_data_escaped_dash_dash(&mut self) { + match self.consume() { + Some('-') => { + self.emit_char('-'); + } + Some('<') => { + self.state = State::ScriptDataEscapedLessThanSign; + } + Some('>') => { + self.state = State::ScriptData; + self.emit_char('>'); + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + self.state = State::ScriptDataEscaped; + self.emit_char('\u{FFFD}'); + } + None => { + self.emit_error("eof-in-script-html-comment-like-text"); + self.emit_eof(); + } + Some(c) => { + self.state = State::ScriptDataEscaped; + self.emit_char(c); + } + } + } + + // 13.2.5.23 Script data escaped less-than sign state + fn state_script_data_escaped_less_than_sign(&mut self) { + match self.peek() { + Some('/') => { + self.consume(); + self.temp_buffer.clear(); + self.state = State::ScriptDataEscapedEndTagOpen; + } + Some(c) if c.is_ascii_alphabetic() => { + self.temp_buffer.clear(); + self.emit_char('<'); + self.state = State::ScriptDataDoubleEscapeStart; + } + _ => { + self.emit_char('<'); + self.state = State::ScriptDataEscaped; + } + } + } + + // 13.2.5.24 Script data escaped end tag open state + fn state_script_data_escaped_end_tag_open(&mut self) { + match self.peek() { + Some(c) if c.is_ascii_alphabetic() => { + self.create_end_tag(); + self.state = State::ScriptDataEscapedEndTagName; + } + _ => { + self.emit_char('<'); + self.emit_char('/'); + self.state = State::ScriptDataEscaped; + } + } + } + + // 13.2.5.25 Script data escaped end tag name state + fn state_script_data_escaped_end_tag_name(&mut self) { + match self.consume() { + Some(c @ ('\t' | '\n' | '\x0C' | ' ')) => { + if self.is_appropriate_end_tag() { + self.state = State::BeforeAttributeName; + } else { + self.emit_char('<'); + self.emit_char('/'); + self.emit_temp_buffer_chars(); + self.reconsume(c); + self.state = State::ScriptDataEscaped; + } + } + Some('/') => { + if self.is_appropriate_end_tag() { + self.state = State::SelfClosingStartTag; + } else { + self.emit_char('<'); + self.emit_char('/'); + self.emit_temp_buffer_chars(); + self.reconsume('/'); + self.state = State::ScriptDataEscaped; + } + } + Some('>') => { + if self.is_appropriate_end_tag() { + self.state = State::Data; + self.emit_current_tag(); + } else { + self.emit_char('<'); + self.emit_char('/'); + self.emit_temp_buffer_chars(); + self.reconsume('>'); + self.state = State::ScriptDataEscaped; + } + } + Some(c) if c.is_ascii_alphabetic() => { + self.current_tag_name.push(c.to_ascii_lowercase()); + self.temp_buffer.push(c); + } + None => { + self.emit_char('<'); + self.emit_char('/'); + self.emit_temp_buffer_chars(); + self.state = State::ScriptDataEscaped; + } + Some(c) => { + self.emit_char('<'); + self.emit_char('/'); + self.emit_temp_buffer_chars(); + self.reconsume(c); + self.state = State::ScriptDataEscaped; + } + } + } + + // 13.2.5.26 Script data double escape start state + fn state_script_data_double_escape_start(&mut self) { + match self.consume() { + Some(c @ ('\t' | '\n' | '\x0C' | ' ' | '/' | '>')) => { + if self.temp_buffer == "script" { + self.state = State::ScriptDataDoubleEscaped; + } else { + self.state = State::ScriptDataEscaped; + } + self.emit_char(c); + } + Some(c) if c.is_ascii_alphabetic() => { + self.temp_buffer.push(c.to_ascii_lowercase()); + self.emit_char(c); + } + _ => { + if let Some(c) = self.input[self.pos..].chars().next() { + self.reconsume(c); + } + self.state = State::ScriptDataEscaped; + } + } + } + + // 13.2.5.27 Script data double escaped state + fn state_script_data_double_escaped(&mut self) { + match self.consume() { + Some('-') => { + self.state = State::ScriptDataDoubleEscapedDash; + self.emit_char('-'); + } + Some('<') => { + self.state = State::ScriptDataDoubleEscapedLessThanSign; + self.emit_char('<'); + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + self.emit_char('\u{FFFD}'); + } + None => { + self.emit_error("eof-in-script-html-comment-like-text"); + self.emit_eof(); + } + Some(c) => { + self.emit_char(c); + } + } + } + + // 13.2.5.28 Script data double escaped dash state + fn state_script_data_double_escaped_dash(&mut self) { + match self.consume() { + Some('-') => { + self.state = State::ScriptDataDoubleEscapedDashDash; + self.emit_char('-'); + } + Some('<') => { + self.state = State::ScriptDataDoubleEscapedLessThanSign; + self.emit_char('<'); + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + self.state = State::ScriptDataDoubleEscaped; + self.emit_char('\u{FFFD}'); + } + None => { + self.emit_error("eof-in-script-html-comment-like-text"); + self.emit_eof(); + } + Some(c) => { + self.state = State::ScriptDataDoubleEscaped; + self.emit_char(c); + } + } + } + + // 13.2.5.29 Script data double escaped dash dash state + fn state_script_data_double_escaped_dash_dash(&mut self) { + match self.consume() { + Some('-') => { + self.emit_char('-'); + } + Some('<') => { + self.state = State::ScriptDataDoubleEscapedLessThanSign; + self.emit_char('<'); + } + Some('>') => { + self.state = State::ScriptData; + self.emit_char('>'); + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + self.state = State::ScriptDataDoubleEscaped; + self.emit_char('\u{FFFD}'); + } + None => { + self.emit_error("eof-in-script-html-comment-like-text"); + self.emit_eof(); + } + Some(c) => { + self.state = State::ScriptDataDoubleEscaped; + self.emit_char(c); + } + } + } + + // 13.2.5.30 Script data double escaped less-than sign state + fn state_script_data_double_escaped_less_than_sign(&mut self) { + match self.peek() { + Some('/') => { + self.consume(); + self.temp_buffer.clear(); + self.state = State::ScriptDataDoubleEscapeEnd; + self.emit_char('/'); + } + _ => { + self.state = State::ScriptDataDoubleEscaped; + } + } + } + + // 13.2.5.31 Script data double escape end state + fn state_script_data_double_escape_end(&mut self) { + match self.consume() { + Some(c @ ('\t' | '\n' | '\x0C' | ' ' | '/' | '>')) => { + if self.temp_buffer == "script" { + self.state = State::ScriptDataEscaped; + } else { + self.state = State::ScriptDataDoubleEscaped; + } + self.emit_char(c); + } + Some(c) if c.is_ascii_alphabetic() => { + self.temp_buffer.push(c.to_ascii_lowercase()); + self.emit_char(c); + } + _ => { + if let Some(c) = self.input[self.pos..].chars().next() { + self.reconsume(c); + } + self.state = State::ScriptDataDoubleEscaped; + } + } + } + + // 13.2.5.32 Before attribute name state + fn state_before_attribute_name(&mut self) { + match self.consume() { + Some('\t' | '\n' | '\x0C' | ' ') => { + // Ignore whitespace. + } + Some('/' | '>') | None => { + if let Some(c) = self.input[self.pos.saturating_sub(1)..].chars().next() { + if c == '/' || c == '>' { + self.reconsume(c); + } + } + if self.pos == self.input.len() { + // EOF – reconsume handled by AfterAttributeName + } + self.state = State::AfterAttributeName; + } + Some('=') => { + self.emit_error("unexpected-equals-sign-before-attribute-name"); + self.start_new_attr(); + self.current_attr_name.push('='); + self.state = State::AttributeName; + } + Some(c) => { + self.start_new_attr(); + self.reconsume(c); + self.state = State::AttributeName; + } + } + } + + // 13.2.5.33 Attribute name state + fn state_attribute_name(&mut self) { + // Fast path: scan ahead for ASCII lowercase attribute name bytes. + let bytes = self.input.as_bytes(); + let start = self.pos; + let mut i = start; + while i < bytes.len() { + let b = bytes[i]; + match b { + b'\t' + | b'\n' + | 0x0C + | b' ' + | b'/' + | b'>' + | b'=' + | 0 + | b'"' + | b'\'' + | b'<' + | 0x80..=0xFF => break, + b'A'..=b'Z' => { + self.current_attr_name.push((b + 32) as char); + i += 1; + } + _ => { + self.current_attr_name.push(b as char); + i += 1; + } + } + } + self.pos = i; + + match self.consume() { + Some(c @ ('\t' | '\n' | '\x0C' | ' ' | '/' | '>')) => { + self.reconsume(c); + self.state = State::AfterAttributeName; + } + Some('=') => { + self.state = State::BeforeAttributeValue; + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + self.current_attr_name.push('\u{FFFD}'); + } + Some(c @ ('"' | '\'' | '<')) => { + self.emit_error("unexpected-character-in-attribute-name"); + self.current_attr_name.push(c); + } + None => { + self.reconsume('\0'); // will be handled by after-attr + self.pos = self.input.len(); // stay at EOF + self.state = State::AfterAttributeName; + } + Some(c) => { + self.current_attr_name.push(c.to_ascii_lowercase()); + } + } + } + + // 13.2.5.34 After attribute name state + fn state_after_attribute_name(&mut self) { + match self.consume() { + Some('\t' | '\n' | '\x0C' | ' ') => { + // Ignore. + } + Some('/') => { + self.state = State::SelfClosingStartTag; + } + Some('=') => { + self.state = State::BeforeAttributeValue; + } + Some('>') => { + self.state = State::Data; + self.emit_current_tag(); + } + None => { + self.emit_error("eof-in-tag"); + self.emit_eof(); + } + Some(c) => { + self.start_new_attr(); + self.reconsume(c); + self.state = State::AttributeName; + } + } + } + + // 13.2.5.35 Before attribute value state + fn state_before_attribute_value(&mut self) { + match self.consume() { + Some('\t' | '\n' | '\x0C' | ' ') => { + // Ignore. + } + Some('"') => { + self.state = State::AttributeValueDoubleQuoted; + } + Some('\'') => { + self.state = State::AttributeValueSingleQuoted; + } + Some('>') => { + self.emit_error("missing-attribute-value"); + self.state = State::Data; + self.emit_current_tag(); + } + Some(c) => { + self.reconsume(c); + self.state = State::AttributeValueUnquoted; + } + None => { + self.reconsume('\0'); + self.pos = self.input.len(); + self.state = State::AttributeValueUnquoted; + } + } + } + + // 13.2.5.36 Attribute value (double-quoted) state + fn state_attribute_value_double_quoted(&mut self) { + // Fast path: scan ahead for plain attribute value bytes (no ", &, \0). + let bytes = self.input.as_bytes(); + let start = self.pos; + let mut i = start; + while i < bytes.len() { + match bytes[i] { + b'"' | b'&' | 0 => break, + _ => i += 1, + } + } + if i > start { + self.current_attr_value.push_str(&self.input[start..i]); + self.pos = i; + } + + match self.consume() { + Some('"') => { + self.state = State::AfterAttributeValueQuoted; + } + Some('&') => { + self.return_state = State::AttributeValueDoubleQuoted; + self.state = State::CharacterReference; + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + self.current_attr_value.push('\u{FFFD}'); + } + None => { + self.emit_error("eof-in-tag"); + self.emit_eof(); + } + Some(c) => { + self.current_attr_value.push(c); + } + } + } + + // 13.2.5.37 Attribute value (single-quoted) state + fn state_attribute_value_single_quoted(&mut self) { + // Fast path: scan ahead for plain attribute value bytes (no ', &, \0). + let bytes = self.input.as_bytes(); + let start = self.pos; + let mut i = start; + while i < bytes.len() { + match bytes[i] { + b'\'' | b'&' | 0 => break, + _ => i += 1, + } + } + if i > start { + self.current_attr_value.push_str(&self.input[start..i]); + self.pos = i; + } + + match self.consume() { + Some('\'') => { + self.state = State::AfterAttributeValueQuoted; + } + Some('&') => { + self.return_state = State::AttributeValueSingleQuoted; + self.state = State::CharacterReference; + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + self.current_attr_value.push('\u{FFFD}'); + } + None => { + self.emit_error("eof-in-tag"); + self.emit_eof(); + } + Some(c) => { + self.current_attr_value.push(c); + } + } + } + + // 13.2.5.38 Attribute value (unquoted) state + fn state_attribute_value_unquoted(&mut self) { + match self.consume() { + Some('\t' | '\n' | '\x0C' | ' ') => { + self.state = State::BeforeAttributeName; + } + Some('&') => { + self.return_state = State::AttributeValueUnquoted; + self.state = State::CharacterReference; + } + Some('>') => { + self.state = State::Data; + self.emit_current_tag(); + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + self.current_attr_value.push('\u{FFFD}'); + } + Some(c @ ('"' | '\'' | '<' | '=' | '`')) => { + self.emit_error("unexpected-character-in-unquoted-attribute-value"); + self.current_attr_value.push(c); + } + None => { + self.emit_error("eof-in-tag"); + self.emit_eof(); + } + Some(c) => { + self.current_attr_value.push(c); + } + } + } + + // 13.2.5.39 After attribute value (quoted) state + fn state_after_attribute_value_quoted(&mut self) { + match self.consume() { + Some('\t' | '\n' | '\x0C' | ' ') => { + self.state = State::BeforeAttributeName; + } + Some('/') => { + self.state = State::SelfClosingStartTag; + } + Some('>') => { + self.state = State::Data; + self.emit_current_tag(); + } + None => { + self.emit_error("eof-in-tag"); + self.emit_eof(); + } + Some(c) => { + self.emit_error("missing-whitespace-between-attributes"); + self.reconsume(c); + self.state = State::BeforeAttributeName; + } + } + } + + // 13.2.5.40 Self-closing start tag state + fn state_self_closing_start_tag(&mut self) { + match self.consume() { + Some('>') => { + self.current_tag_self_closing = true; + self.state = State::Data; + self.emit_current_tag(); + } + None => { + self.emit_error("eof-in-tag"); + self.emit_eof(); + } + Some(c) => { + self.emit_error("unexpected-solidus-in-tag"); + self.reconsume(c); + self.state = State::BeforeAttributeName; + } + } + } + + // 13.2.5.41 Bogus comment state + fn state_bogus_comment(&mut self) { + match self.consume() { + Some('>') => { + self.state = State::Data; + self.emit_comment(); + } + None => { + self.emit_comment(); + self.emit_eof(); + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + self.current_comment.push('\u{FFFD}'); + } + Some(c) => { + self.current_comment.push(c); + } + } + } + + // 13.2.5.42 Markup declaration open state + fn state_markup_declaration_open(&mut self) { + if self.next_chars_are_ascii_ci("--") { + self.pos += 2; + self.current_comment.clear(); + self.state = State::CommentStart; + } else if self.next_chars_are_ascii_ci("DOCTYPE") { + self.pos += 7; + self.state = State::Doctype; + } else if self.next_chars_are_ascii_ci("[CDATA[") { + self.pos += 7; + if self.allow_cdata { + // In foreign content: treat as CDATA section. + self.state = State::CdataSection; + } else { + // In HTML content: parse error, treat as bogus comment. + self.emit_error("cdata-in-html-content"); + self.current_comment = "[CDATA[".to_string(); + self.state = State::BogusComment; + } + } else { + self.emit_error("incorrectly-opened-comment"); + self.current_comment.clear(); + self.state = State::BogusComment; + } + } + + // 13.2.5.43 Comment start state + fn state_comment_start(&mut self) { + match self.consume() { + Some('-') => { + self.state = State::CommentStartDash; + } + Some('>') => { + self.emit_error("abrupt-closing-of-empty-comment"); + self.state = State::Data; + self.emit_comment(); + } + Some(c) => { + self.reconsume(c); + self.state = State::Comment; + } + None => { + self.reconsume('\0'); + self.pos = self.input.len(); + self.state = State::Comment; + } + } + } + + // 13.2.5.44 Comment start dash state + fn state_comment_start_dash(&mut self) { + match self.consume() { + Some('-') => { + self.state = State::CommentEnd; + } + Some('>') => { + self.emit_error("abrupt-closing-of-empty-comment"); + self.state = State::Data; + self.emit_comment(); + } + None => { + self.emit_error("eof-in-comment"); + self.emit_comment(); + self.emit_eof(); + } + Some(c) => { + self.current_comment.push('-'); + self.reconsume(c); + self.state = State::Comment; + } + } + } + + // 13.2.5.45 Comment state + fn state_comment(&mut self) { + match self.consume() { + Some('<') => { + self.current_comment.push('<'); + self.state = State::CommentLessThanSign; + } + Some('-') => { + self.state = State::CommentEndDash; + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + self.current_comment.push('\u{FFFD}'); + } + None => { + self.emit_error("eof-in-comment"); + self.emit_comment(); + self.emit_eof(); + } + Some(c) => { + self.current_comment.push(c); + } + } + } + + // 13.2.5.46 Comment less-than sign state + fn state_comment_less_than_sign(&mut self) { + match self.consume() { + Some('!') => { + self.current_comment.push('!'); + self.state = State::CommentLessThanSignBang; + } + Some('<') => { + self.current_comment.push('<'); + } + Some(c) => { + self.reconsume(c); + self.state = State::Comment; + } + None => { + self.state = State::Comment; + } + } + } + + // 13.2.5.47 Comment less-than sign bang state + fn state_comment_less_than_sign_bang(&mut self) { + match self.peek() { + Some('-') => { + self.consume(); + self.state = State::CommentLessThanSignBangDash; + } + _ => { + self.state = State::Comment; + } + } + } + + // 13.2.5.48 Comment less-than sign bang dash state + fn state_comment_less_than_sign_bang_dash(&mut self) { + match self.peek() { + Some('-') => { + self.consume(); + self.state = State::CommentLessThanSignBangDashDash; + } + _ => { + self.state = State::CommentEndDash; + } + } + } + + // 13.2.5.49 Comment less-than sign bang dash dash state + fn state_comment_less_than_sign_bang_dash_dash(&mut self) { + match self.peek() { + Some('>') | None => { + self.state = State::CommentEnd; + } + _ => { + self.emit_error("nested-comment"); + self.state = State::CommentEnd; + } + } + } + + // 13.2.5.50 Comment end dash state + fn state_comment_end_dash(&mut self) { + match self.consume() { + Some('-') => { + self.state = State::CommentEnd; + } + None => { + self.emit_error("eof-in-comment"); + self.emit_comment(); + self.emit_eof(); + } + Some(c) => { + self.current_comment.push('-'); + self.reconsume(c); + self.state = State::Comment; + } + } + } + + // 13.2.5.51 Comment end state + fn state_comment_end(&mut self) { + match self.consume() { + Some('>') => { + self.state = State::Data; + self.emit_comment(); + } + Some('!') => { + self.state = State::CommentEndBang; + } + Some('-') => { + self.current_comment.push('-'); + } + None => { + self.emit_error("eof-in-comment"); + self.emit_comment(); + self.emit_eof(); + } + Some(c) => { + self.current_comment.push('-'); + self.current_comment.push('-'); + self.reconsume(c); + self.state = State::Comment; + } + } + } + + // 13.2.5.52 Comment end bang state + fn state_comment_end_bang(&mut self) { + match self.consume() { + Some('-') => { + self.current_comment.push('-'); + self.current_comment.push('-'); + self.current_comment.push('!'); + self.state = State::CommentEndDash; + } + Some('>') => { + self.emit_error("incorrectly-closed-comment"); + self.state = State::Data; + self.emit_comment(); + } + None => { + self.emit_error("eof-in-comment"); + self.emit_comment(); + self.emit_eof(); + } + Some(c) => { + self.current_comment.push('-'); + self.current_comment.push('-'); + self.current_comment.push('!'); + self.reconsume(c); + self.state = State::Comment; + } + } + } + + // 13.2.5.53 DOCTYPE state + fn state_doctype(&mut self) { + match self.consume() { + Some('\t' | '\n' | '\x0C' | ' ') => { + self.state = State::BeforeDoctypeName; + } + Some('>') => { + self.reconsume('>'); + self.state = State::BeforeDoctypeName; + } + None => { + self.emit_error("eof-in-doctype"); + self.create_doctype(); + self.current_doctype_force_quirks = true; + self.emit_doctype(); + self.emit_eof(); + } + Some(c) => { + self.emit_error("missing-whitespace-before-doctype-name"); + self.reconsume(c); + self.state = State::BeforeDoctypeName; + } + } + } + + // 13.2.5.54 Before DOCTYPE name state + fn state_before_doctype_name(&mut self) { + match self.consume() { + Some('\t' | '\n' | '\x0C' | ' ') => { + // Ignore. + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + self.create_doctype(); + self.current_doctype_name = Some(String::from('\u{FFFD}')); + self.state = State::DoctypeName; + } + Some('>') => { + self.emit_error("missing-doctype-name"); + self.create_doctype(); + self.current_doctype_force_quirks = true; + self.state = State::Data; + self.emit_doctype(); + } + None => { + self.emit_error("eof-in-doctype"); + self.create_doctype(); + self.current_doctype_force_quirks = true; + self.emit_doctype(); + self.emit_eof(); + } + Some(c) => { + self.create_doctype(); + self.current_doctype_name = Some(String::from(c.to_ascii_lowercase())); + self.state = State::DoctypeName; + } + } + } + + // 13.2.5.55 DOCTYPE name state + fn state_doctype_name(&mut self) { + match self.consume() { + Some('\t' | '\n' | '\x0C' | ' ') => { + self.state = State::AfterDoctypeName; + } + Some('>') => { + self.state = State::Data; + self.emit_doctype(); + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + if let Some(ref mut name) = self.current_doctype_name { + name.push('\u{FFFD}'); + } + } + None => { + self.emit_error("eof-in-doctype"); + self.current_doctype_force_quirks = true; + self.emit_doctype(); + self.emit_eof(); + } + Some(c) => { + if let Some(ref mut name) = self.current_doctype_name { + name.push(c.to_ascii_lowercase()); + } + } + } + } + + // 13.2.5.56 After DOCTYPE name state + fn state_after_doctype_name(&mut self) { + match self.consume() { + Some('\t' | '\n' | '\x0C' | ' ') => { + // Ignore. + } + Some('>') => { + self.state = State::Data; + self.emit_doctype(); + } + None => { + self.emit_error("eof-in-doctype"); + self.current_doctype_force_quirks = true; + self.emit_doctype(); + self.emit_eof(); + } + Some(c) => { + // Check for PUBLIC or SYSTEM keywords. + self.reconsume(c); + if self.next_chars_are_ascii_ci("PUBLIC") { + self.pos += 6; + self.state = State::AfterDoctypePublicKeyword; + } else if self.next_chars_are_ascii_ci("SYSTEM") { + self.pos += 6; + self.state = State::AfterDoctypeSystemKeyword; + } else { + self.consume(); // re-consume the char we put back + self.emit_error("invalid-character-sequence-after-doctype-name"); + self.current_doctype_force_quirks = true; + self.state = State::BogusDoctype; + } + } + } + } + + // 13.2.5.57 After DOCTYPE public keyword state + fn state_after_doctype_public_keyword(&mut self) { + match self.consume() { + Some('\t' | '\n' | '\x0C' | ' ') => { + self.state = State::BeforeDoctypePublicIdentifier; + } + Some('"') => { + self.emit_error("missing-whitespace-after-doctype-public-keyword"); + self.current_doctype_public_id = Some(String::new()); + self.state = State::DoctypePublicIdentifierDoubleQuoted; + } + Some('\'') => { + self.emit_error("missing-whitespace-after-doctype-public-keyword"); + self.current_doctype_public_id = Some(String::new()); + self.state = State::DoctypePublicIdentifierSingleQuoted; + } + Some('>') => { + self.emit_error("missing-doctype-public-identifier"); + self.current_doctype_force_quirks = true; + self.state = State::Data; + self.emit_doctype(); + } + None => { + self.emit_error("eof-in-doctype"); + self.current_doctype_force_quirks = true; + self.emit_doctype(); + self.emit_eof(); + } + Some(_) => { + self.emit_error("missing-quote-before-doctype-public-identifier"); + self.current_doctype_force_quirks = true; + self.state = State::BogusDoctype; + } + } + } + + // 13.2.5.58 Before DOCTYPE public identifier state + fn state_before_doctype_public_identifier(&mut self) { + match self.consume() { + Some('\t' | '\n' | '\x0C' | ' ') => { + // Ignore. + } + Some('"') => { + self.current_doctype_public_id = Some(String::new()); + self.state = State::DoctypePublicIdentifierDoubleQuoted; + } + Some('\'') => { + self.current_doctype_public_id = Some(String::new()); + self.state = State::DoctypePublicIdentifierSingleQuoted; + } + Some('>') => { + self.emit_error("missing-doctype-public-identifier"); + self.current_doctype_force_quirks = true; + self.state = State::Data; + self.emit_doctype(); + } + None => { + self.emit_error("eof-in-doctype"); + self.current_doctype_force_quirks = true; + self.emit_doctype(); + self.emit_eof(); + } + Some(_) => { + self.emit_error("missing-quote-before-doctype-public-identifier"); + self.current_doctype_force_quirks = true; + self.state = State::BogusDoctype; + } + } + } + + // 13.2.5.59 DOCTYPE public identifier (double-quoted) state + fn state_doctype_public_identifier_double_quoted(&mut self) { + match self.consume() { + Some('"') => { + self.state = State::AfterDoctypePublicIdentifier; + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + if let Some(ref mut id) = self.current_doctype_public_id { + id.push('\u{FFFD}'); + } + } + Some('>') => { + self.emit_error("abrupt-doctype-public-identifier"); + self.current_doctype_force_quirks = true; + self.state = State::Data; + self.emit_doctype(); + } + None => { + self.emit_error("eof-in-doctype"); + self.current_doctype_force_quirks = true; + self.emit_doctype(); + self.emit_eof(); + } + Some(c) => { + if let Some(ref mut id) = self.current_doctype_public_id { + id.push(c); + } + } + } + } + + // 13.2.5.60 DOCTYPE public identifier (single-quoted) state + fn state_doctype_public_identifier_single_quoted(&mut self) { + match self.consume() { + Some('\'') => { + self.state = State::AfterDoctypePublicIdentifier; + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + if let Some(ref mut id) = self.current_doctype_public_id { + id.push('\u{FFFD}'); + } + } + Some('>') => { + self.emit_error("abrupt-doctype-public-identifier"); + self.current_doctype_force_quirks = true; + self.state = State::Data; + self.emit_doctype(); + } + None => { + self.emit_error("eof-in-doctype"); + self.current_doctype_force_quirks = true; + self.emit_doctype(); + self.emit_eof(); + } + Some(c) => { + if let Some(ref mut id) = self.current_doctype_public_id { + id.push(c); + } + } + } + } + + // 13.2.5.61 After DOCTYPE public identifier state + fn state_after_doctype_public_identifier(&mut self) { + match self.consume() { + Some('\t' | '\n' | '\x0C' | ' ') => { + self.state = State::BetweenDoctypePublicAndSystemIdentifiers; + } + Some('>') => { + self.state = State::Data; + self.emit_doctype(); + } + Some('"') => { + self.emit_error("missing-whitespace-between-doctype-public-and-system-identifiers"); + self.current_doctype_system_id = Some(String::new()); + self.state = State::DoctypeSystemIdentifierDoubleQuoted; + } + Some('\'') => { + self.emit_error("missing-whitespace-between-doctype-public-and-system-identifiers"); + self.current_doctype_system_id = Some(String::new()); + self.state = State::DoctypeSystemIdentifierSingleQuoted; + } + None => { + self.emit_error("eof-in-doctype"); + self.current_doctype_force_quirks = true; + self.emit_doctype(); + self.emit_eof(); + } + Some(_) => { + self.emit_error("missing-quote-before-doctype-system-identifier"); + self.current_doctype_force_quirks = true; + self.state = State::BogusDoctype; + } + } + } + + // 13.2.5.62 Between DOCTYPE public and system identifiers state + fn state_between_doctype_public_and_system_identifiers(&mut self) { + match self.consume() { + Some('\t' | '\n' | '\x0C' | ' ') => { + // Ignore. + } + Some('>') => { + self.state = State::Data; + self.emit_doctype(); + } + Some('"') => { + self.current_doctype_system_id = Some(String::new()); + self.state = State::DoctypeSystemIdentifierDoubleQuoted; + } + Some('\'') => { + self.current_doctype_system_id = Some(String::new()); + self.state = State::DoctypeSystemIdentifierSingleQuoted; + } + None => { + self.emit_error("eof-in-doctype"); + self.current_doctype_force_quirks = true; + self.emit_doctype(); + self.emit_eof(); + } + Some(_) => { + self.emit_error("missing-quote-before-doctype-system-identifier"); + self.current_doctype_force_quirks = true; + self.state = State::BogusDoctype; + } + } + } + + // 13.2.5.63 After DOCTYPE system keyword state + fn state_after_doctype_system_keyword(&mut self) { + match self.consume() { + Some('\t' | '\n' | '\x0C' | ' ') => { + self.state = State::BeforeDoctypeSystemIdentifier; + } + Some('"') => { + self.emit_error("missing-whitespace-after-doctype-system-keyword"); + self.current_doctype_system_id = Some(String::new()); + self.state = State::DoctypeSystemIdentifierDoubleQuoted; + } + Some('\'') => { + self.emit_error("missing-whitespace-after-doctype-system-keyword"); + self.current_doctype_system_id = Some(String::new()); + self.state = State::DoctypeSystemIdentifierSingleQuoted; + } + Some('>') => { + self.emit_error("missing-doctype-system-identifier"); + self.current_doctype_force_quirks = true; + self.state = State::Data; + self.emit_doctype(); + } + None => { + self.emit_error("eof-in-doctype"); + self.current_doctype_force_quirks = true; + self.emit_doctype(); + self.emit_eof(); + } + Some(_) => { + self.emit_error("missing-quote-before-doctype-system-identifier"); + self.current_doctype_force_quirks = true; + self.state = State::BogusDoctype; + } + } + } + + // 13.2.5.64 Before DOCTYPE system identifier state + fn state_before_doctype_system_identifier(&mut self) { + match self.consume() { + Some('\t' | '\n' | '\x0C' | ' ') => { + // Ignore. + } + Some('"') => { + self.current_doctype_system_id = Some(String::new()); + self.state = State::DoctypeSystemIdentifierDoubleQuoted; + } + Some('\'') => { + self.current_doctype_system_id = Some(String::new()); + self.state = State::DoctypeSystemIdentifierSingleQuoted; + } + Some('>') => { + self.emit_error("missing-doctype-system-identifier"); + self.current_doctype_force_quirks = true; + self.state = State::Data; + self.emit_doctype(); + } + None => { + self.emit_error("eof-in-doctype"); + self.current_doctype_force_quirks = true; + self.emit_doctype(); + self.emit_eof(); + } + Some(_) => { + self.emit_error("missing-quote-before-doctype-system-identifier"); + self.current_doctype_force_quirks = true; + self.state = State::BogusDoctype; + } + } + } + + // 13.2.5.65 DOCTYPE system identifier (double-quoted) state + fn state_doctype_system_identifier_double_quoted(&mut self) { + match self.consume() { + Some('"') => { + self.state = State::AfterDoctypeSystemIdentifier; + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + if let Some(ref mut id) = self.current_doctype_system_id { + id.push('\u{FFFD}'); + } + } + Some('>') => { + self.emit_error("abrupt-doctype-system-identifier"); + self.current_doctype_force_quirks = true; + self.state = State::Data; + self.emit_doctype(); + } + None => { + self.emit_error("eof-in-doctype"); + self.current_doctype_force_quirks = true; + self.emit_doctype(); + self.emit_eof(); + } + Some(c) => { + if let Some(ref mut id) = self.current_doctype_system_id { + id.push(c); + } + } + } + } + + // 13.2.5.66 DOCTYPE system identifier (single-quoted) state + fn state_doctype_system_identifier_single_quoted(&mut self) { + match self.consume() { + Some('\'') => { + self.state = State::AfterDoctypeSystemIdentifier; + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + if let Some(ref mut id) = self.current_doctype_system_id { + id.push('\u{FFFD}'); + } + } + Some('>') => { + self.emit_error("abrupt-doctype-system-identifier"); + self.current_doctype_force_quirks = true; + self.state = State::Data; + self.emit_doctype(); + } + None => { + self.emit_error("eof-in-doctype"); + self.current_doctype_force_quirks = true; + self.emit_doctype(); + self.emit_eof(); + } + Some(c) => { + if let Some(ref mut id) = self.current_doctype_system_id { + id.push(c); + } + } + } + } + + // 13.2.5.67 After DOCTYPE system identifier state + fn state_after_doctype_system_identifier(&mut self) { + match self.consume() { + Some('\t' | '\n' | '\x0C' | ' ') => { + // Ignore. + } + Some('>') => { + self.state = State::Data; + self.emit_doctype(); + } + None => { + self.emit_error("eof-in-doctype"); + self.current_doctype_force_quirks = true; + self.emit_doctype(); + self.emit_eof(); + } + Some(_) => { + self.emit_error("unexpected-character-after-doctype-system-identifier"); + // Do NOT set force-quirks. + self.state = State::BogusDoctype; + } + } + } + + // 13.2.5.68 Bogus DOCTYPE state + fn state_bogus_doctype(&mut self) { + match self.consume() { + Some('>') => { + self.state = State::Data; + self.emit_doctype(); + } + Some('\0') => { + self.emit_error("unexpected-null-character"); + // Ignore. + } + None => { + self.emit_doctype(); + self.emit_eof(); + } + Some(_) => { + // Ignore. + } + } + } + + // 13.2.5.69 CDATA section state + fn state_cdata_section(&mut self) { + match self.consume() { + Some(']') => { + self.state = State::CdataSectionBracket; + } + None => { + self.emit_error("eof-in-cdata"); + self.emit_eof(); + } + Some(c) => { + self.emit_char(c); + } + } + } + + // 13.2.5.70 CDATA section bracket state + fn state_cdata_section_bracket(&mut self) { + if let Some(']') = self.peek() { + self.consume(); + self.state = State::CdataSectionEnd; + } else { + self.emit_char(']'); + self.state = State::CdataSection; + } + } + + // 13.2.5.71 CDATA section end state + fn state_cdata_section_end(&mut self) { + match self.peek() { + Some(']') => { + self.consume(); + self.emit_char(']'); + } + Some('>') => { + self.consume(); + self.state = State::Data; + } + _ => { + self.emit_char(']'); + self.emit_char(']'); + self.state = State::CdataSection; + } + } + } + + // 13.2.5.72 Character reference state + fn state_character_reference(&mut self) { + self.temp_buffer.clear(); + self.temp_buffer.push('&'); + match self.peek() { + Some(c) if c.is_ascii_alphanumeric() => { + self.state = State::NamedCharacterReference; + } + Some('#') => { + self.consume(); + self.temp_buffer.push('#'); + self.state = State::NumericCharacterReference; + } + _ => { + self.flush_code_points_consumed_as_char_ref(); + self.state = self.return_state; + } + } + } + + // 13.2.5.73 Named character reference state + fn state_named_character_reference(&mut self) { + use crate::html5::entities::is_legacy_named_entity; + + // The WHATWG spec says: consume the maximum number of characters + // possible where the consumed characters are one of the identifiers + // in the named character references table. The table has entries + // both with and without trailing semicolons. Entries without + // semicolons (the "legacy" set) may match even when no `;` follows; + // all other entries require the `;` to be present in the input. + let start = self.pos; + // (replacement, end_pos, had_semicolon) + let mut best_semicolon_match: Option<(&str, usize)> = None; + let mut best_legacy_match: Option<(&str, usize)> = None; + let mut name = String::new(); + + // Greedily consume characters that could be part of an entity name. + while let Some(c) = self.peek() { + if c.is_ascii_alphanumeric() { + name.push(c); + self.consume(); + if let Some(replacement) = lookup_entity(&name) { + if self.peek() == Some(';') { + self.consume(); + best_semicolon_match = Some((replacement, self.pos)); + // A semicolon match is always the best. Keep going + // would be past the `;`, so stop. + break; + } + // Without semicolon: only valid for legacy entities. + if is_legacy_named_entity(&name) { + best_legacy_match = Some((replacement, self.pos)); + } + } + } else { + break; + } + } + + // Prefer semicolon match, then legacy match, then no match. + let best_match = best_semicolon_match + .map(|(r, p)| (r, p, true)) + .or(best_legacy_match.map(|(r, p)| (r, p, false))); + + if let Some((replacement, end_pos, had_semicolon)) = best_match { + self.pos = end_pos; + + if !had_semicolon { + // Check if we are in an attribute value and the next char + // is `=` or alphanumeric — if so, treat as not a reference. + if is_attr_value_state(self.return_state) { + if let Some(next) = self.peek() { + if next == '=' || next.is_ascii_alphanumeric() { + self.pos = start; + self.flush_code_points_consumed_as_char_ref(); + self.state = self.return_state; + return; + } + } + } + self.emit_error("missing-semicolon-after-character-reference"); + } + + self.temp_buffer.clear(); + self.temp_buffer.push_str(replacement); + self.flush_code_points_consumed_as_char_ref(); + self.state = self.return_state; + } else { + // No match found — rewind to start. + self.pos = start; + self.flush_code_points_consumed_as_char_ref(); + self.state = State::AmbiguousAmpersand; + } + } + + // 13.2.5.74 Ambiguous ampersand state + fn state_ambiguous_ampersand(&mut self) { + match self.consume() { + Some(c) if c.is_ascii_alphanumeric() => { + if is_attr_value_state(self.return_state) { + self.current_attr_value.push(c); + } else { + self.emit_char(c); + } + } + Some(';') => { + self.emit_error("unknown-named-character-reference"); + self.reconsume(';'); + self.state = self.return_state; + } + Some(c) => { + self.reconsume(c); + self.state = self.return_state; + } + None => { + self.state = self.return_state; + } + } + } + + // 13.2.5.75 Numeric character reference state + fn state_numeric_character_reference(&mut self) { + self.char_ref_code = 0; + match self.peek() { + Some('x' | 'X') => { + let c = self.consume(); + if let Some(ch) = c { + self.temp_buffer.push(ch); + } + self.state = State::HexadecimalCharacterReferenceStart; + } + _ => { + self.state = State::DecimalCharacterReferenceStart; + } + } + } + + // 13.2.5.76 Hexadecimal character reference start state + fn state_hexadecimal_character_reference_start(&mut self) { + match self.peek() { + Some(c) if c.is_ascii_hexdigit() => { + self.state = State::HexadecimalCharacterReference; + } + _ => { + self.emit_error("absence-of-digits-in-numeric-character-reference"); + self.flush_code_points_consumed_as_char_ref(); + self.state = self.return_state; + } + } + } + + // 13.2.5.77 Decimal character reference start state + fn state_decimal_character_reference_start(&mut self) { + match self.peek() { + Some(c) if c.is_ascii_digit() => { + self.state = State::DecimalCharacterReference; + } + _ => { + self.emit_error("absence-of-digits-in-numeric-character-reference"); + self.flush_code_points_consumed_as_char_ref(); + self.state = self.return_state; + } + } + } + + // 13.2.5.78 Hexadecimal character reference state + fn state_hexadecimal_character_reference(&mut self) { + match self.consume() { + Some(c) if c.is_ascii_hexdigit() => { + self.char_ref_code = self + .char_ref_code + .saturating_mul(16) + .saturating_add(hex_digit_value(c)); + } + Some(';') => { + self.state = State::NumericCharacterReferenceEnd; + } + Some(c) => { + self.emit_error("missing-semicolon-after-character-reference"); + self.reconsume(c); + self.state = State::NumericCharacterReferenceEnd; + } + None => { + self.emit_error("missing-semicolon-after-character-reference"); + self.state = State::NumericCharacterReferenceEnd; + } + } + } + + // 13.2.5.79 Decimal character reference state + fn state_decimal_character_reference(&mut self) { + match self.consume() { + Some(c) if c.is_ascii_digit() => { + self.char_ref_code = self + .char_ref_code + .saturating_mul(10) + .saturating_add(u32::from(c as u8 - b'0')); + } + Some(';') => { + self.state = State::NumericCharacterReferenceEnd; + } + Some(c) => { + self.emit_error("missing-semicolon-after-character-reference"); + self.reconsume(c); + self.state = State::NumericCharacterReferenceEnd; + } + None => { + self.emit_error("missing-semicolon-after-character-reference"); + self.state = State::NumericCharacterReferenceEnd; + } + } + } + + // 13.2.5.80 Numeric character reference end state + fn state_numeric_character_reference_end(&mut self) { + let code = self.char_ref_code; + let ch = if code == 0 { + self.emit_error("null-character-reference"); + '\u{FFFD}' + } else if code > 0x10_FFFF { + self.emit_error("character-reference-outside-unicode-range"); + '\u{FFFD}' + } else if is_surrogate(code) { + self.emit_error("surrogate-character-reference"); + '\u{FFFD}' + } else if is_noncharacter(code) { + self.emit_error("noncharacter-character-reference"); + // The spec says to use the code point anyway for noncharacters. + char_from_u32(code) + } else if code == 0x0D || (is_control(code) && !is_ascii_whitespace_codepoint(code)) { + self.emit_error("control-character-reference"); + numeric_ref_replacement(code) + } else { + char_from_u32(code) + }; + + self.temp_buffer.clear(); + self.temp_buffer.push(ch); + self.flush_code_points_consumed_as_char_ref(); + self.state = self.return_state; + } +} + +// --------------------------------------------------------------------------- +// Free helper functions +// --------------------------------------------------------------------------- + +/// Returns true if the given state is one of the attribute value states. +fn is_attr_value_state(state: State) -> bool { + matches!( + state, + State::AttributeValueDoubleQuoted + | State::AttributeValueSingleQuoted + | State::AttributeValueUnquoted + ) +} + +/// Convert a hex digit character to its numeric value. +fn hex_digit_value(c: char) -> u32 { + match c { + '0'..='9' => u32::from(c as u8 - b'0'), + 'a'..='f' => u32::from(c as u8 - b'a') + 10, + 'A'..='F' => u32::from(c as u8 - b'A') + 10, + _ => 0, + } +} + +/// Convert a `u32` to a `char`, falling back to U+FFFD. +fn char_from_u32(code: u32) -> char { + char::from_u32(code).unwrap_or('\u{FFFD}') +} + +/// Is the code point a surrogate (U+D800..=U+DFFF)? +fn is_surrogate(code: u32) -> bool { + (0xD800..=0xDFFF).contains(&code) +} + +/// Is the code point a noncharacter? +fn is_noncharacter(code: u32) -> bool { + matches!( + code, + 0xFDD0 + ..=0xFDEF + | 0xFFFE + | 0xFFFF + | 0x1_FFFE + | 0x1_FFFF + | 0x2_FFFE + | 0x2_FFFF + | 0x3_FFFE + | 0x3_FFFF + | 0x4_FFFE + | 0x4_FFFF + | 0x5_FFFE + | 0x5_FFFF + | 0x6_FFFE + | 0x6_FFFF + | 0x7_FFFE + | 0x7_FFFF + | 0x8_FFFE + | 0x8_FFFF + | 0x9_FFFE + | 0x9_FFFF + | 0xA_FFFE + | 0xA_FFFF + | 0xB_FFFE + | 0xB_FFFF + | 0xC_FFFE + | 0xC_FFFF + | 0xD_FFFE + | 0xD_FFFF + | 0xE_FFFE + | 0xE_FFFF + | 0xF_FFFE + | 0xF_FFFF + | 0x10_FFFE + | 0x10_FFFF + ) +} + +/// Is the code point a control character (C0 or DEL range)? +fn is_control(code: u32) -> bool { + matches!(code, 0x00..=0x1F | 0x7F..=0x9F) +} + +/// Is the code point one of the ASCII whitespace code points? +fn is_ascii_whitespace_codepoint(code: u32) -> bool { + matches!(code, 0x09 | 0x0A | 0x0C | 0x0D | 0x20) +} + +/// The WHATWG numeric character reference replacement table (section 13.2.5.80). +/// +/// Certain control-character code points in the 0x80..=0x9F range are +/// replaced with Windows-1252 code points. +fn numeric_ref_replacement(code: u32) -> char { + match code { + 0x80 => '\u{20AC}', + 0x82 => '\u{201A}', + 0x83 => '\u{0192}', + 0x84 => '\u{201E}', + 0x85 => '\u{2026}', + 0x86 => '\u{2020}', + 0x87 => '\u{2021}', + 0x88 => '\u{02C6}', + 0x89 => '\u{2030}', + 0x8A => '\u{0160}', + 0x8B => '\u{2039}', + 0x8C => '\u{0152}', + 0x8E => '\u{017D}', + 0x91 => '\u{2018}', + 0x92 => '\u{2019}', + 0x93 => '\u{201C}', + 0x94 => '\u{201D}', + 0x95 => '\u{2022}', + 0x96 => '\u{2013}', + 0x97 => '\u{2014}', + 0x98 => '\u{02DC}', + 0x99 => '\u{2122}', + 0x9A => '\u{0161}', + 0x9B => '\u{203A}', + 0x9C => '\u{0153}', + 0x9E => '\u{017E}', + 0x9F => '\u{0178}', + _ => char_from_u32(code), + } +} + +/// Normalize newlines per WHATWG spec §13.2.3. +/// +/// Replace every CR (U+000D) and every CR+LF pair with a single LF (U+000A). +fn normalize_newlines(input: &str) -> String { + let mut result = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\r' { + result.push('\n'); + if chars.peek() == Some(&'\n') { + chars.next(); + } + } else { + result.push(c); + } + } + result +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + /// Collect all tokens from a tokenizer. + fn tokenize(input: &str) -> Vec<Token> { + let mut tok = Tokenizer::new(input); + let mut tokens = Vec::new(); + loop { + let t = tok.next_token(); + if t == Token::Eof { + tokens.push(t); + break; + } + tokens.push(t); + } + tokens + } + + /// Collect all non-EOF tokens. + fn tokenize_body(input: &str) -> Vec<Token> { + tokenize(input) + .into_iter() + .filter(|t| *t != Token::Eof) + .collect() + } + + /// Helper: collect just the errors. + fn tokenize_errors(input: &str) -> Vec<String> { + let mut tok = Tokenizer::new(input); + loop { + if tok.next_token() == Token::Eof { + break; + } + } + tok.errors().iter().map(|e| e.code.to_string()).collect() + } + + #[test] + fn test_basic_start_tag() { + let tokens = tokenize_body("<div>"); + assert_eq!( + tokens, + vec![Token::StartTag { + name: "div".into(), + attributes: vec![], + self_closing: false, + }] + ); + } + + #[test] + fn test_basic_end_tag() { + let tokens = tokenize_body("</div>"); + assert_eq!(tokens, vec![Token::EndTag { name: "div".into() }]); + } + + #[test] + fn test_self_closing_tag() { + let tokens = tokenize_body("<br/>"); + assert_eq!( + tokens, + vec![Token::StartTag { + name: "br".into(), + attributes: vec![], + self_closing: true, + }] + ); + } + + #[test] + fn test_tag_with_attributes() { + let tokens = tokenize_body(r#"<div class="main" id='app'>"#); + assert_eq!( + tokens, + vec![Token::StartTag { + name: "div".into(), + attributes: vec![ + Attribute { + name: "class".into(), + value: "main".into(), + }, + Attribute { + name: "id".into(), + value: "app".into(), + }, + ], + self_closing: false, + }] + ); + } + + #[test] + fn test_unquoted_attribute() { + let tokens = tokenize_body("<div class=main>"); + assert_eq!( + tokens, + vec![Token::StartTag { + name: "div".into(), + attributes: vec![Attribute { + name: "class".into(), + value: "main".into(), + }], + self_closing: false, + }] + ); + } + + #[test] + fn test_boolean_attribute() { + let tokens = tokenize_body("<input disabled>"); + assert_eq!( + tokens, + vec![Token::StartTag { + name: "input".into(), + attributes: vec![Attribute { + name: "disabled".into(), + value: String::new(), + }], + self_closing: false, + }] + ); + } + + #[test] + fn test_comment() { + let tokens = tokenize_body("<!-- hello -->"); + assert_eq!(tokens, vec![Token::Comment(" hello ".into())]); + } + + #[test] + fn test_doctype() { + let tokens = tokenize_body("<!DOCTYPE html>"); + assert_eq!( + tokens, + vec![Token::Doctype { + name: Some("html".into()), + public_id: None, + system_id: None, + force_quirks: false, + }] + ); + } + + #[test] + fn test_doctype_with_public_system() { + let tokens = tokenize_body( + r#"<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">"#, + ); + assert_eq!( + tokens, + vec![Token::Doctype { + name: Some("html".into()), + public_id: Some("-//W3C//DTD HTML 4.01//EN".into()), + system_id: Some("http://www.w3.org/TR/html4/strict.dtd".into()), + force_quirks: false, + }] + ); + } + + #[test] + fn test_character_data() { + let tokens = tokenize_body("hello"); + assert_eq!( + tokens, + vec![ + Token::Character('h'), + Token::Character('e'), + Token::Character('l'), + Token::Character('l'), + Token::Character('o'), + ] + ); + } + + #[test] + fn test_named_character_reference() { + let tokens = tokenize_body("&amp;"); + assert_eq!(tokens, vec![Token::Character('&')]); + } + + #[test] + fn test_named_character_reference_nbsp() { + let tokens = tokenize_body("&nbsp;"); + assert_eq!(tokens, vec![Token::Character('\u{00A0}')]); + } + + #[test] + fn test_numeric_decimal_reference() { + let tokens = tokenize_body("&#65;"); + assert_eq!(tokens, vec![Token::Character('A')]); + } + + #[test] + fn test_numeric_hex_reference() { + let tokens = tokenize_body("&#x41;"); + assert_eq!(tokens, vec![Token::Character('A')]); + } + + #[test] + fn test_numeric_hex_reference_uppercase() { + let tokens = tokenize_body("&#X41;"); + assert_eq!(tokens, vec![Token::Character('A')]); + } + + #[test] + fn test_numeric_reference_replacement_table() { + // &#128; (0x80) should map to Euro sign U+20AC + let tokens = tokenize_body("&#128;"); + assert_eq!(tokens, vec![Token::Character('\u{20AC}')]); + } + + #[test] + fn test_numeric_reference_null() { + // &#0; should map to U+FFFD + let tokens = tokenize_body("&#0;"); + assert_eq!(tokens, vec![Token::Character('\u{FFFD}')]); + } + + #[test] + fn test_set_state_rawtext() { + let mut tok = Tokenizer::new("<div>ignored</div>"); + // Simulate tree builder switching to RawText after seeing a style tag. + tok.set_state(State::RawText); + // In RawText, everything is character tokens until `</` + matching tag. + let first = tok.next_token(); + assert_eq!(first, Token::Character('<')); + } + + #[test] + fn test_set_state_rcdata() { + let mut tok = Tokenizer::new("hello &amp; world"); + tok.set_state(State::RcData); + let mut chars = String::new(); + loop { + match tok.next_token() { + Token::Character(c) => chars.push(c), + Token::Eof => break, + _ => {} + } + } + assert_eq!(chars, "hello & world"); + } + + #[test] + fn test_eof_in_tag_error() { + let errors = tokenize_errors("<div"); + assert!(errors.contains(&"eof-in-tag".to_string())); + } + + #[test] + fn test_eof_in_comment_error() { + let errors = tokenize_errors("<!-- unclosed"); + assert!(errors.contains(&"eof-in-comment".to_string())); + } + + #[test] + fn test_missing_attribute_value_error() { + let errors = tokenize_errors("<div class=>"); + assert!(errors.contains(&"missing-attribute-value".to_string())); + } + + #[test] + fn test_eof_before_tag_name() { + let tokens = tokenize_body("<"); + // Should emit '<' as character, then EOF (which we filter). + assert_eq!(tokens, vec![Token::Character('<')]); + } + + #[test] + fn test_duplicate_attributes_ignored() { + let tokens = tokenize_body(r#"<div a="1" a="2">"#); + assert_eq!( + tokens, + vec![Token::StartTag { + name: "div".into(), + attributes: vec![Attribute { + name: "a".into(), + value: "1".into(), + }], + self_closing: false, + }] + ); + } + + #[test] + fn test_tag_name_case_lowered() { + let tokens = tokenize_body("<DIV>"); + assert_eq!( + tokens, + vec![Token::StartTag { + name: "div".into(), + attributes: vec![], + self_closing: false, + }] + ); + } + + #[test] + fn test_cdata_section() { + // In non-foreign content (allow_cdata=false), CDATA is treated as bogus comment. + let tokens = tokenize_body("<![CDATA[hello]]>"); + assert_eq!(tokens, vec![Token::Comment("[CDATA[hello]]".into())]); + } + + #[test] + fn test_cdata_section_in_foreign_content() { + // When allow_cdata=true, CDATA content is emitted as character tokens. + let mut tok = Tokenizer::new("<![CDATA[hello]]>"); + tok.set_allow_cdata(true); + let mut chars = String::new(); + loop { + match tok.next_token() { + Token::Character(c) => chars.push(c), + Token::Eof => break, + _ => {} + } + } + assert_eq!(chars, "hello"); + } + + #[test] + fn test_bogus_comment_from_question_mark() { + let tokens = tokenize_body("<?xml version='1.0'?>"); + // Should be treated as a bogus comment. + assert_eq!(tokens, vec![Token::Comment("?xml version='1.0'?".into())]); + } + + #[test] + fn test_null_in_data_emitted() { + // Per WHATWG spec, Data state emits null as-is (with parse error). + let tokens = tokenize_body("\0"); + assert_eq!(tokens, vec![Token::Character('\0')]); + } + + #[test] + fn test_empty_input() { + let tokens = tokenize(""); + assert_eq!(tokens, vec![Token::Eof]); + } + + #[test] + fn test_multiple_attributes_mixed_quoting() { + let tokens = tokenize_body(r#"<a href="url" target=_blank title='tip'>"#); + assert_eq!( + tokens, + vec![Token::StartTag { + name: "a".into(), + attributes: vec![ + Attribute { + name: "href".into(), + value: "url".into(), + }, + Attribute { + name: "target".into(), + value: "_blank".into(), + }, + Attribute { + name: "title".into(), + value: "tip".into(), + }, + ], + self_closing: false, + }] + ); + } + + #[test] + fn test_character_reference_in_attribute() { + let tokens = tokenize_body(r#"<a href="?a=1&amp;b=2">"#); + assert_eq!( + tokens, + vec![Token::StartTag { + name: "a".into(), + attributes: vec![Attribute { + name: "href".into(), + value: "?a=1&b=2".into(), + }], + self_closing: false, + }] + ); + } + + #[test] + fn test_abrupt_closing_of_empty_comment() { + let tokens = tokenize_body("<!-->"); + assert_eq!(tokens, vec![Token::Comment(String::new())]); + let errors = tokenize_errors("<!-->"); + assert!(errors.contains(&"abrupt-closing-of-empty-comment".to_string())); + } + + #[test] + fn test_incorrectly_opened_comment() { + let tokens = tokenize_body("<!foo>"); + assert_eq!(tokens, vec![Token::Comment("foo".into())]); + let errors = tokenize_errors("<!foo>"); + assert!(errors.contains(&"incorrectly-opened-comment".to_string())); + } + + #[test] + fn test_eof_in_doctype() { + let tokens = tokenize_body("<!DOCTYPE"); + assert_eq!( + tokens, + vec![Token::Doctype { + name: None, + public_id: None, + system_id: None, + force_quirks: true, + }] + ); + let errors = tokenize_errors("<!DOCTYPE"); + assert!(errors.contains(&"eof-in-doctype".to_string())); + } +} diff --git a/browser/vendor/xmloxide/src/html5/tree_builder.rs b/browser/vendor/xmloxide/src/html5/tree_builder.rs new file mode 100644 index 000000000..89dcf1652 --- /dev/null +++ b/browser/vendor/xmloxide/src/html5/tree_builder.rs @@ -0,0 +1,4721 @@ +//! WHATWG HTML5 tree construction algorithm. +//! +//! This module implements the tree construction stage of the HTML parsing +//! algorithm as defined in the WHATWG HTML Living Standard. It consumes +//! tokens from the [`Tokenizer`] and builds a [`Document`] tree. +//! +//! See <https://html.spec.whatwg.org/multipage/parsing.html#tree-construction> + +use crate::error::{ErrorSeverity, ParseDiagnostic, ParseError, SourceLocation}; +use crate::html5::tokenizer::{self, State, Token, Tokenizer}; +use crate::tree::{Document, NodeId, NodeKind}; + +// --------------------------------------------------------------------------- +// Insertion mode +// --------------------------------------------------------------------------- + +/// All insertion modes from the WHATWG specification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InsertionMode { + Initial, + BeforeHtml, + BeforeHead, + InHead, + InHeadNoscript, + AfterHead, + InBody, + Text, + InTable, + InTableText, + InCaption, + InColumnGroup, + InTableBody, + InRow, + InCell, + InSelect, + InSelectInTable, + InTemplate, + AfterBody, + InFrameset, + AfterFrameset, + AfterAfterBody, + AfterAfterFrameset, +} + +// --------------------------------------------------------------------------- +// Quirks mode +// --------------------------------------------------------------------------- + +/// Document compatibility mode determined by the DOCTYPE. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum QuirksMode { + NoQuirks, + Quirks, + LimitedQuirks, +} + +// Full quirks mode public identifier prefixes +const QUIRKS_PREFIXES: &[&str] = &[ + "+//silmaril//dtd html pro v0r11 19970101//", + "-//as//dtd html 3.0 aswedit extensions//", + "-//advasoft ltd//dtd html 3.0 aswedit + extensions//", + "-//ietf//dtd html 2.0 level 1//", + "-//ietf//dtd html 2.0 level 2//", + "-//ietf//dtd html 2.0 strict level 1//", + "-//ietf//dtd html 2.0 strict level 2//", + "-//ietf//dtd html 2.0 strict//", + "-//ietf//dtd html 2.0//", + "-//ietf//dtd html 2.1e//", + "-//ietf//dtd html 3.0//", + "-//ietf//dtd html 3.2 final//", + "-//ietf//dtd html 3.2//", + "-//ietf//dtd html 3//", + "-//ietf//dtd html level 0//", + "-//ietf//dtd html level 1//", + "-//ietf//dtd html level 2//", + "-//ietf//dtd html level 3//", + "-//ietf//dtd html strict level 0//", + "-//ietf//dtd html strict level 1//", + "-//ietf//dtd html strict level 2//", + "-//ietf//dtd html strict level 3//", + "-//ietf//dtd html strict//", + "-//ietf//dtd html//", + "-//metrius//dtd metrius presentational//", + "-//microsoft//dtd internet explorer 2.0 html strict//", + "-//microsoft//dtd internet explorer 2.0 html//", + "-//microsoft//dtd internet explorer 2.0 tables//", + "-//microsoft//dtd internet explorer 3.0 html strict//", + "-//microsoft//dtd internet explorer 3.0 html//", + "-//microsoft//dtd internet explorer 3.0 tables//", + "-//netscape comm. corp.//dtd html//", + "-//netscape comm. corp.//dtd strict html//", + "-//o'reilly and associates//dtd html 2.0//", + "-//o'reilly and associates//dtd html extended 1.0//", + "-//o'reilly and associates//dtd html extended relaxed 1.0//", + "-//sq//dtd html 2.0 hotmetal + extensions//", + "-//softquad software//dtd hotmetal pro 6.0::19990601::extensions to html 4.0//", + "-//softquad//dtd hotmetal pro 4.0::19971010::extensions to html 4.0//", + "-//spyglass//dtd html 2.0 extended//", + "-//sun microsystems corp.//dtd hotjava html//", + "-//sun microsystems corp.//dtd hotjava strict html//", + "-//w3c//dtd html 3 1995-03-24//", + "-//w3c//dtd html 3.2 draft//", + "-//w3c//dtd html 3.2 final//", + "-//w3c//dtd html 3.2//", + "-//w3c//dtd html 3.2s draft//", + "-//w3c//dtd html 4.0 frameset//", + "-//w3c//dtd html 4.0 transitional//", + "-//w3c//dtd html experimental 19960712//", + "-//w3c//dtd html experimental 970421//", + "-//w3c//dtd w3 html//", + "-//w3o//dtd w3 html 3.0//", + "-//webtechs//dtd mozilla html 2.0//", + "-//webtechs//dtd mozilla html//", +]; + +// Exact matches for quirks +const QUIRKS_EXACT: &[&str] = &[ + "-//w3o//dtd w3 html strict 3.0//en//", + "-/w3c/dtd html 4.0 transitional/en", + "html", +]; + +/// Determine quirks mode from a DOCTYPE token per WHATWG §13.2.6.4.1. +fn determine_quirks_mode( + name: &str, + public_id: Option<&str>, + system_id: Option<&str>, + force_quirks: bool, +) -> QuirksMode { + if force_quirks { + return QuirksMode::Quirks; + } + if !name.eq_ignore_ascii_case("html") { + return QuirksMode::Quirks; + } + let pub_id = public_id.unwrap_or(""); + let pub_lower = pub_id.to_ascii_lowercase(); + + let sys_id = system_id.unwrap_or(""); + let sys_lower = sys_id.to_ascii_lowercase(); + + if sys_lower == "http://www.ibm.com/data/dtd/v11/ibmxhtml1-transitional.dtd" { + return QuirksMode::Quirks; + } + + for exact in QUIRKS_EXACT { + if pub_lower == *exact { + return QuirksMode::Quirks; + } + } + + for prefix in QUIRKS_PREFIXES { + if pub_lower.starts_with(prefix) { + return QuirksMode::Quirks; + } + } + + // Quirks if these prefixes appear and system identifier is missing + if system_id.is_none() + && (pub_lower.starts_with("-//w3c//dtd html 4.01 frameset//") + || pub_lower.starts_with("-//w3c//dtd html 4.01 transitional//")) + { + return QuirksMode::Quirks; + } + + // Limited quirks mode + if pub_lower.starts_with("-//w3c//dtd xhtml 1.0 frameset//") + || pub_lower.starts_with("-//w3c//dtd xhtml 1.0 transitional//") + { + return QuirksMode::LimitedQuirks; + } + if system_id.is_some() + && (pub_lower.starts_with("-//w3c//dtd html 4.01 frameset//") + || pub_lower.starts_with("-//w3c//dtd html 4.01 transitional//")) + { + return QuirksMode::LimitedQuirks; + } + + QuirksMode::NoQuirks +} + +// --------------------------------------------------------------------------- +// Namespace +// --------------------------------------------------------------------------- + +/// The namespace of an element in the tree. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Namespace { + Html, + Svg, + MathMl, +} + +impl Namespace { + fn uri(self) -> &'static str { + match self { + Self::Html => "http://www.w3.org/1999/xhtml", + Self::Svg => "http://www.w3.org/2000/svg", + Self::MathMl => "http://www.w3.org/1998/Math/MathML", + } + } +} + +// --------------------------------------------------------------------------- +// Active formatting list entry +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +enum FormatEntry { + Element { + node_id: NodeId, + name: String, + attrs: Vec<tokenizer::Attribute>, + }, + Marker, +} + +// --------------------------------------------------------------------------- +// Tree build error +// --------------------------------------------------------------------------- + +/// An error encountered during tree construction. +#[derive(Debug, Clone)] +#[allow(dead_code)] +struct TreeBuildError { + message: String, +} + +// --------------------------------------------------------------------------- +// Helper: element metadata stored alongside NodeId on the open elements stack +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +struct StackEntry { + node_id: NodeId, + name: String, + ns: Namespace, + /// True if this is a `MathML` `annotation-xml` element with + /// `encoding="text/html"` or `encoding="application/xhtml+xml"`. + is_html_integration: bool, +} + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +/// Options for HTML5 parsing. +#[derive(Debug, Clone, Default)] +pub struct Html5ParseOptions { + /// Whether scripting is enabled (affects `<noscript>` handling). + pub scripting: bool, + /// If set, parse as a fragment with the given context element tag name. + /// + /// Use plain element names for HTML contexts (e.g. `"body"`, `"select"`) + /// and namespace-prefixed names for foreign contexts (e.g. `"svg svg"`, + /// `"math mi"`). + pub fragment_context: Option<String>, +} + +/// Result of HTML5 parsing, containing the document tree and any parse errors. +/// +/// The WHATWG HTML5 parsing algorithm is designed to handle all input without +/// fatal errors. Parse errors are collected as diagnostics rather than causing +/// failure; the tree is always produced. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::html5::parse_html5_full; +/// +/// let result = parse_html5_full("<p>Unclosed paragraph<p>Next"); +/// assert!(result.errors.is_empty() || !result.errors.is_empty()); // always succeeds +/// let _doc = result.document; // tree is always available +/// ``` +#[derive(Debug)] +pub struct Html5ParseResult { + /// The constructed document tree. + pub document: Document, + /// Parse errors encountered during tokenization and tree construction. + /// + /// These are non-fatal diagnostics — the document tree is still complete. + pub errors: Vec<ParseDiagnostic>, +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Parses an HTML5 string into a [`Document`] with default options. +/// +/// # Errors +/// +/// Returns [`ParseError`] if parsing fails fatally (extremely rare for HTML5, +/// since the algorithm is designed to handle all input). +/// +/// # Examples +/// +/// ``` +/// use xmloxide::html5::parse_html5; +/// +/// let doc = parse_html5("<h1>Hello</h1>").unwrap(); +/// let root = doc.root_element().unwrap(); +/// assert_eq!(doc.node_name(root), Some("html")); +/// ``` +pub fn parse_html5(input: &str) -> Result<Document, ParseError> { + parse_html5_with_options(input, &Html5ParseOptions::default()) +} + +/// Parses an HTML5 string into a [`Document`] with the given options. +/// +/// # Errors +/// +/// Returns [`ParseError`] if parsing fails fatally. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::html5::{parse_html5_with_options, Html5ParseOptions}; +/// +/// let opts = Html5ParseOptions { +/// scripting: false, +/// fragment_context: Some("body".to_string()), +/// }; +/// let doc = parse_html5_with_options("<p>fragment</p>", &opts).unwrap(); +/// ``` +pub fn parse_html5_with_options( + input: &str, + options: &Html5ParseOptions, +) -> Result<Document, ParseError> { + let result = parse_html5_full_with_options(input, options); + Ok(result.document) +} + +/// Parses an HTML5 string and returns the document tree along with all parse +/// errors. +/// +/// Unlike [`parse_html5`], this function always succeeds (no `Result`) and +/// returns collected [`ParseDiagnostic`]s for inspection. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::html5::parse_html5_full; +/// +/// let result = parse_html5_full("<p>Hello</p>"); +/// println!("{} errors", result.errors.len()); +/// ``` +pub fn parse_html5_full(input: &str) -> Html5ParseResult { + parse_html5_full_with_options(input, &Html5ParseOptions::default()) +} + +/// Parses an HTML5 string with the given options, returning the document tree +/// and all parse errors. +pub fn parse_html5_full_with_options(input: &str, options: &Html5ParseOptions) -> Html5ParseResult { + let tokenizer = Tokenizer::new(input); + let mut builder = TreeBuilder::new(tokenizer, options); + builder.run(); + + // Collect tokenizer errors as diagnostics. + let mut errors: Vec<ParseDiagnostic> = builder + .tokenizer + .errors() + .iter() + .map(|e| { + // Compute line/column from byte offset. + let (line, col) = byte_offset_to_line_col(input, e.span); + ParseDiagnostic { + severity: ErrorSeverity::Error, + message: e.code.to_string(), + location: SourceLocation { + line, + column: col, + byte_offset: e.span, + }, + } + }) + .collect(); + + // Append tree construction errors. + for e in &builder.errors { + errors.push(ParseDiagnostic { + severity: ErrorSeverity::Error, + message: e.message.clone(), + location: SourceLocation::default(), + }); + } + + Html5ParseResult { + document: builder.doc, + errors, + } +} + +/// Convert a byte offset into 1-based (line, column) pair. +fn byte_offset_to_line_col(input: &str, offset: usize) -> (u32, u32) { + let bytes = input.as_bytes(); + let end = offset.min(bytes.len()); + let mut line: u32 = 1; + let mut col: u32 = 1; + for &b in &bytes[..end] { + if b == b'\n' { + line += 1; + col = 1; + } else { + col += 1; + } + } + (line, col) +} + +// --------------------------------------------------------------------------- +// Constants: element categories +// --------------------------------------------------------------------------- + +fn is_formatting_element(name: &str) -> bool { + matches!( + name, + "a" | "b" + | "big" + | "code" + | "em" + | "font" + | "i" + | "s" + | "small" + | "strike" + | "strong" + | "tt" + | "u" + | "nobr" + ) +} + +fn is_special_element_ns(name: &str, ns: Namespace) -> bool { + match ns { + Namespace::Html => matches!( + name, + "address" + | "applet" + | "area" + | "article" + | "aside" + | "base" + | "basefont" + | "bgsound" + | "blockquote" + | "body" + | "br" + | "button" + | "caption" + | "center" + | "col" + | "colgroup" + | "dd" + | "details" + | "dir" + | "div" + | "dl" + | "dt" + | "embed" + | "fieldset" + | "figcaption" + | "figure" + | "footer" + | "form" + | "frame" + | "frameset" + | "h1" + | "h2" + | "h3" + | "h4" + | "h5" + | "h6" + | "head" + | "header" + | "hgroup" + | "hr" + | "html" + | "iframe" + | "img" + | "input" + | "keygen" + | "li" + | "link" + | "listing" + | "main" + | "marquee" + | "menu" + | "meta" + | "nav" + | "noembed" + | "noframes" + | "noscript" + | "object" + | "ol" + | "p" + | "param" + | "plaintext" + | "pre" + | "script" + | "search" + | "section" + | "select" + | "source" + | "style" + | "summary" + | "table" + | "tbody" + | "td" + | "template" + | "textarea" + | "tfoot" + | "th" + | "thead" + | "title" + | "tr" + | "track" + | "ul" + | "wbr" + | "xmp" + ), + Namespace::MathMl => { + matches!(name, "mi" | "mo" | "mn" | "ms" | "mtext" | "annotation-xml") + } + Namespace::Svg => matches!(name, "foreignObject" | "desc" | "title"), + } +} + +#[allow(dead_code)] +fn is_void_element(name: &str) -> bool { + matches!( + name, + "area" + | "base" + | "br" + | "col" + | "embed" + | "hr" + | "img" + | "input" + | "link" + | "meta" + | "param" + | "source" + | "track" + | "wbr" + ) +} + +fn is_heading(name: &str) -> bool { + matches!(name, "h1" | "h2" | "h3" | "h4" | "h5" | "h6") +} + +// Scope element sets (WHATWG 13.2.4.2) +fn is_scope_element(name: &str, ns: Namespace) -> bool { + match ns { + Namespace::Html => matches!( + name, + "applet" + | "caption" + | "html" + | "table" + | "td" + | "th" + | "marquee" + | "object" + | "template" + ), + Namespace::MathMl => matches!(name, "mi" | "mo" | "mn" | "ms" | "mtext" | "annotation-xml"), + Namespace::Svg => matches!(name, "foreignObject" | "desc" | "title"), + } +} + +fn is_list_item_scope_element(name: &str, ns: Namespace) -> bool { + is_scope_element(name, ns) || (ns == Namespace::Html && matches!(name, "ol" | "ul")) +} + +fn is_button_scope_element(name: &str, ns: Namespace) -> bool { + is_scope_element(name, ns) || (ns == Namespace::Html && name == "button") +} + +fn is_table_scope_element(name: &str, ns: Namespace) -> bool { + ns == Namespace::Html && matches!(name, "html" | "table" | "template") +} + +fn is_select_scope_element(name: &str, ns: Namespace) -> bool { + // Everything EXCEPT optgroup, option, and elements allowed in the new + // select content model. + !(ns == Namespace::Html + && matches!( + name, + "optgroup" + | "option" + | "button" + | "datalist" + | "div" + | "selectedcontent" + | "b" + | "big" + | "code" + | "em" + | "font" + | "i" + | "s" + | "small" + | "strike" + | "strong" + | "tt" + | "u" + | "a" + | "nobr" + | "keygen" + | "menuitem" + | "hr" + | "img" + | "br" + | "p" + | "span" + | "label" + )) +} + +/// Tags that break out of foreign content back to HTML processing. +fn is_foreign_breakout_tag(name: &str) -> bool { + matches!( + name, + "b" | "big" + | "blockquote" + | "body" + | "br" + | "center" + | "code" + | "dd" + | "details" + | "dialog" + | "dir" + | "div" + | "dl" + | "dt" + | "em" + | "embed" + | "h1" + | "h2" + | "h3" + | "h4" + | "h5" + | "h6" + | "head" + | "hr" + | "i" + | "img" + | "li" + | "listing" + | "menu" + | "meta" + | "nobr" + | "ol" + | "p" + | "pre" + | "ruby" + | "s" + | "small" + | "span" + | "strong" + | "strike" + | "sub" + | "sup" + | "table" + | "tt" + | "u" + | "ul" + | "var" + ) +} + +/// Adjust SVG element names from lowercased parser output to proper camelCase. +fn adjust_svg_tag_name(name: &str) -> String { + match name { + "altglyph" => "altGlyph".to_string(), + "altglyphdef" => "altGlyphDef".to_string(), + "altglyphitem" => "altGlyphItem".to_string(), + "animatecolor" => "animateColor".to_string(), + "animatemotion" => "animateMotion".to_string(), + "animatetransform" => "animateTransform".to_string(), + "clippath" => "clipPath".to_string(), + "feblend" => "feBlend".to_string(), + "fecolormatrix" => "feColorMatrix".to_string(), + "fecomponenttransfer" => "feComponentTransfer".to_string(), + "fecomposite" => "feComposite".to_string(), + "feconvolvematrix" => "feConvolveMatrix".to_string(), + "fediffuselighting" => "feDiffuseLighting".to_string(), + "fedisplacementmap" => "feDisplacementMap".to_string(), + "fedistantlight" => "feDistantLight".to_string(), + "fedropshadow" => "feDropShadow".to_string(), + "feflood" => "feFlood".to_string(), + "fefunca" => "feFuncA".to_string(), + "fefuncb" => "feFuncB".to_string(), + "fefuncg" => "feFuncG".to_string(), + "fefuncr" => "feFuncR".to_string(), + "fegaussianblur" => "feGaussianBlur".to_string(), + "feimage" => "feImage".to_string(), + "femerge" => "feMerge".to_string(), + "femergenode" => "feMergeNode".to_string(), + "femorphology" => "feMorphology".to_string(), + "feoffset" => "feOffset".to_string(), + "fepointlight" => "fePointLight".to_string(), + "fespecularlighting" => "feSpecularLighting".to_string(), + "fespotlight" => "feSpotLight".to_string(), + "fetile" => "feTile".to_string(), + "feturbulence" => "feTurbulence".to_string(), + "foreignobject" => "foreignObject".to_string(), + "glyphref" => "glyphRef".to_string(), + "lineargradient" => "linearGradient".to_string(), + "radialgradient" => "radialGradient".to_string(), + "textpath" => "textPath".to_string(), + _ => name.to_string(), + } +} + +/// Adjust SVG attribute names from lowercased to proper camelCase per WHATWG. +fn adjust_svg_attributes(name: &str) -> &str { + match name { + "attributename" => "attributeName", + "attributetype" => "attributeType", + "basefrequency" => "baseFrequency", + "baseprofile" => "baseProfile", + "calcmode" => "calcMode", + "clippathunits" => "clipPathUnits", + "diffuseconstant" => "diffuseConstant", + "edgemode" => "edgeMode", + "filterunits" => "filterUnits", + "glyphref" => "glyphRef", + "gradienttransform" => "gradientTransform", + "gradientunits" => "gradientUnits", + "kernelmatrix" => "kernelMatrix", + "kernelunitlength" => "kernelUnitLength", + "keypoints" => "keyPoints", + "keysplines" => "keySplines", + "keytimes" => "keyTimes", + "lengthadjust" => "lengthAdjust", + "limitingconeangle" => "limitingConeAngle", + "markerheight" => "markerHeight", + "markerunits" => "markerUnits", + "markerwidth" => "markerWidth", + "maskcontentunits" => "maskContentUnits", + "maskunits" => "maskUnits", + "numoctaves" => "numOctaves", + "pathlength" => "pathLength", + "patterncontentunits" => "patternContentUnits", + "patterntransform" => "patternTransform", + "patternunits" => "patternUnits", + "pointsatx" => "pointsAtX", + "pointsaty" => "pointsAtY", + "pointsatz" => "pointsAtZ", + "preservealpha" => "preserveAlpha", + "preserveaspectratio" => "preserveAspectRatio", + "primitiveunits" => "primitiveUnits", + "refx" => "refX", + "refy" => "refY", + "repeatcount" => "repeatCount", + "repeatdur" => "repeatDur", + "requiredextensions" => "requiredExtensions", + "requiredfeatures" => "requiredFeatures", + "specularconstant" => "specularConstant", + "specularexponent" => "specularExponent", + "spreadmethod" => "spreadMethod", + "startoffset" => "startOffset", + "stddeviation" => "stdDeviation", + "stitchtiles" => "stitchTiles", + "surfacescale" => "surfaceScale", + "systemlanguage" => "systemLanguage", + "tablevalues" => "tableValues", + "targetx" => "targetX", + "targety" => "targetY", + "textlength" => "textLength", + "viewbox" => "viewBox", + "viewtarget" => "viewTarget", + "xchannelselector" => "xChannelSelector", + "ychannelselector" => "yChannelSelector", + "zoomandpan" => "zoomAndPan", + _ => name, + } +} + +/// Adjust `MathML` attribute names. +fn adjust_mathml_attributes(name: &str) -> &str { + match name { + "definitionurl" => "definitionURL", + _ => name, + } +} + +/// Parse a foreign attribute name into (prefix, `local_name`, namespace). +fn parse_foreign_attr(name: &str) -> (Option<&str>, &str, Option<&str>) { + match name { + "xlink:actuate" | "xlink:arcrole" | "xlink:href" | "xlink:role" | "xlink:show" + | "xlink:title" | "xlink:type" => { + let local = &name[6..]; // skip "xlink:" + (Some("xlink"), local, Some("http://www.w3.org/1999/xlink")) + } + "xml:lang" | "xml:space" => { + let local = &name[4..]; // skip "xml:" + ( + Some("xml"), + local, + Some("http://www.w3.org/XML/1998/namespace"), + ) + } + "xmlns" => (None, "xmlns", Some("http://www.w3.org/2000/xmlns/")), + "xmlns:xlink" => ( + Some("xmlns"), + "xlink", + Some("http://www.w3.org/2000/xmlns/"), + ), + _ => (None, name, None), + } +} + +// --------------------------------------------------------------------------- +// TreeBuilder +// --------------------------------------------------------------------------- + +/// The HTML5 tree builder state machine. +#[allow(clippy::struct_excessive_bools)] +struct TreeBuilder<'a> { + tokenizer: Tokenizer<'a>, + doc: Document, + mode: InsertionMode, + original_mode: InsertionMode, + open_elements: Vec<StackEntry>, + active_formatting: Vec<FormatEntry>, + head_pointer: Option<NodeId>, + form_pointer: Option<NodeId>, + #[allow(dead_code)] + scripting: bool, + frameset_ok: bool, + foster_parenting: bool, + template_modes: Vec<InsertionMode>, + pending_table_chars: Vec<char>, + /// When true, the next `Character('\n')` token is dropped (leading newline + /// stripping after `<pre>`, `<listing>`, and `<textarea>`). + skip_next_lf: bool, + quirks_mode: QuirksMode, + /// For fragment parsing: the context element name and namespace. + fragment_context: Option<(String, Namespace)>, + #[allow(dead_code)] + errors: Vec<TreeBuildError>, +} + +impl<'a> TreeBuilder<'a> { + fn new(tokenizer: Tokenizer<'a>, options: &Html5ParseOptions) -> Self { + let fragment_context = options.fragment_context.as_ref().map(|ctx| { + // Parse "svg elementname" / "math elementname" / "elementname" + if let Some(name) = ctx.strip_prefix("svg ") { + (name.to_string(), Namespace::Svg) + } else if let Some(name) = ctx.strip_prefix("math ") { + (name.to_string(), Namespace::MathMl) + } else { + (ctx.clone(), Namespace::Html) + } + }); + Self { + tokenizer, + doc: Document::new(), + mode: InsertionMode::Initial, + original_mode: InsertionMode::Initial, + open_elements: Vec::new(), + active_formatting: Vec::new(), + head_pointer: None, + form_pointer: None, + scripting: options.scripting, + frameset_ok: true, + foster_parenting: false, + template_modes: Vec::new(), + pending_table_chars: Vec::new(), + skip_next_lf: false, + quirks_mode: QuirksMode::NoQuirks, + fragment_context, + errors: Vec::new(), + } + } + + // ----------------------------------------------------------------------- + // Main loop + // ----------------------------------------------------------------------- + + /// Initialize the parser for fragment parsing (WHATWG §13.2.1). + fn initialize_fragment(&mut self) { + let Some((ref ctx_name, ctx_ns)) = self.fragment_context.clone() else { + return; + }; + + // Step 5: Create a root html element, append to document, push onto stack. + let html_id = self.doc.create_node(NodeKind::Element { + name: "html".to_string(), + prefix: None, + namespace: None, + attributes: Vec::new(), + }); + self.doc.append_child(self.doc.root(), html_id); + self.open_elements.push(StackEntry { + node_id: html_id, + name: "html".to_string(), + ns: Namespace::Html, + is_html_integration: false, + }); + + // Step 6: If context is template, push InTemplate onto template modes. + if ctx_name == "template" && ctx_ns == Namespace::Html { + self.template_modes.push(InsertionMode::InTemplate); + } + + // Step 8: Set tokenizer state based on context element. + if ctx_ns == Namespace::Html { + match ctx_name.as_str() { + "title" | "textarea" => { + self.tokenizer.set_state(State::RcData); + self.tokenizer.set_last_start_tag(ctx_name); + } + "style" | "xmp" | "iframe" | "noembed" | "noframes" => { + self.tokenizer.set_state(State::RawText); + self.tokenizer.set_last_start_tag(ctx_name); + } + "script" => { + self.tokenizer.set_state(State::ScriptData); + } + "noscript" if self.scripting => { + self.tokenizer.set_state(State::RawText); + self.tokenizer.set_last_start_tag(ctx_name); + } + "plaintext" => { + self.tokenizer.set_state(State::Plaintext); + } + _ => {} + } + } + + // For foreign (SVG/MathML) context elements, push the context + // element onto the stack so that tokens are processed in foreign + // content mode. The element is appended to the html root so that + // child insertion works, but fragment serialization returns its + // children (not the context element itself). + if ctx_ns != Namespace::Html { + let ns_uri = Some(ctx_ns.uri().to_string()); + let ctx_id = self.doc.create_node(NodeKind::Element { + name: ctx_name.clone(), + prefix: None, + namespace: ns_uri, + attributes: Vec::new(), + }); + self.doc.append_child(html_id, ctx_id); + + let is_html_integration = ctx_ns == Namespace::Svg + && matches!(ctx_name.as_str(), "foreignObject" | "desc" | "title"); + self.open_elements.push(StackEntry { + node_id: ctx_id, + name: ctx_name.clone(), + ns: ctx_ns, + is_html_integration, + }); + } + + // Step 10: Reset the insertion mode appropriately. + // (reset_insertion_mode uses fragment_context for the "last" node case) + self.reset_insertion_mode(); + + // Step 12: Set frameset_ok to false. + self.frameset_ok = false; + } + + fn run(&mut self) { + if self.fragment_context.is_some() { + self.initialize_fragment(); + } + loop { + // Inform the tokenizer whether the adjusted current node is in + // a foreign namespace (controls CDATA section handling). + let in_foreign = self + .open_elements + .last() + .is_some_and(|el| el.ns != Namespace::Html); + self.tokenizer.set_allow_cdata(in_foreign); + let token = self.tokenizer.next_token(); + // Leading newline stripping after <pre>, <listing>, <textarea>. + if self.skip_next_lf { + self.skip_next_lf = false; + if token == Token::Character('\n') { + continue; + } + } + + // Fast path: batch consecutive non-null Character tokens in InBody + // mode when there's no foreign content. This avoids per-character + // overhead from process_token dispatch and insertion point lookups. + if self.mode == InsertionMode::InBody && !in_foreign { + if let Token::Character(c) = token { + if c != '\0' { + let mut buf = String::new(); + if !is_ascii_whitespace(c) { + self.frameset_ok = false; + } + buf.push(c); + // Drain consecutive characters from the pending queue. + loop { + let next = self.tokenizer.next_token(); + if let Token::Character(c2) = next { + if c2 == '\0' { + // Null in body: parse error, ignored per spec. + } else { + if !is_ascii_whitespace(c2) { + self.frameset_ok = false; + } + buf.push(c2); + } + } else { + // Non-character token: insert buffered text, then + // process this token normally. + if !buf.is_empty() { + self.reconstruct_formatting(); + self.insert_characters(&buf); + } + if next == Token::Eof { + self.process_token(next); + self.populate_selectedcontent(); + return; + } + // Re-check foreign state before processing next. + let in_foreign_now = self + .open_elements + .last() + .is_some_and(|el| el.ns != Namespace::Html); + self.tokenizer.set_allow_cdata(in_foreign_now); + self.process_token(next); + break; + } + } + continue; + } + } + } + + let is_eof = token == Token::Eof; + self.process_token(token); + if is_eof { + break; + } + } + self.populate_selectedcontent(); + } + + /// Populate `<selectedcontent>` elements inside `<select>` by cloning + /// the content of the selected (or first) `<option>` into them. + fn populate_selectedcontent(&mut self) { + // Collect all selectedcontent elements + let all_nodes: Vec<NodeId> = self.doc.descendants(self.doc.root()).collect(); + let mut selectedcontent_nodes: Vec<NodeId> = Vec::new(); + for &nid in &all_nodes { + if let NodeKind::Element { ref name, .. } = self.doc.node(nid).kind { + if name == "selectedcontent" { + selectedcontent_nodes.push(nid); + } + } + } + + for sc_id in selectedcontent_nodes { + // Walk up to find the containing <select> + let mut select_id = None; + let mut ancestor = self.doc.parent(sc_id); + while let Some(a) = ancestor { + if let NodeKind::Element { ref name, .. } = self.doc.node(a).kind { + if name == "select" { + select_id = Some(a); + break; + } + } + ancestor = self.doc.parent(a); + } + let Some(select_id) = select_id else { + continue; + }; + + // Find the selected option (or first option) inside the select + let option_children: Vec<NodeId> = self.doc.descendants(select_id).collect(); + let mut first_option: Option<NodeId> = None; + let mut selected_option: Option<NodeId> = None; + for &nid in &option_children { + if let NodeKind::Element { + ref name, + ref attributes, + .. + } = self.doc.node(nid).kind + { + if name == "option" { + if first_option.is_none() { + first_option = Some(nid); + } + if attributes.iter().any(|a| a.name == "selected") { + selected_option = Some(nid); + } + } + } + } + + let source = selected_option.or(first_option); + let Some(source) = source else { + continue; + }; + + // Clone all children of the source option into selectedcontent + let children: Vec<NodeId> = self.doc.children(source).collect(); + for child in children { + self.deep_clone_into(child, sc_id); + } + } + } + + /// Deep-clone a node and all its descendants, appending the clone to `parent`. + fn deep_clone_into(&mut self, source: NodeId, parent: NodeId) { + let kind = self.doc.node(source).kind.clone(); + let clone_id = self.doc.create_node(kind); + self.doc.append_child(parent, clone_id); + let children: Vec<NodeId> = self.doc.children(source).collect(); + for child in children { + self.deep_clone_into(child, clone_id); + } + } + + #[allow(clippy::too_many_lines)] + fn process_token(&mut self, token: Token) { + // Determine whether to use normal insertion mode rules or foreign content. + // Per WHATWG §13.2.6. + if self.should_use_foreign_content_rules(&token) { + self.handle_foreign_content(token); + return; + } + + match self.mode { + InsertionMode::Initial => self.handle_initial(token), + InsertionMode::BeforeHtml => self.handle_before_html(token), + InsertionMode::BeforeHead => self.handle_before_head(token), + InsertionMode::InHead => self.handle_in_head(token), + InsertionMode::InHeadNoscript => self.handle_in_head_noscript(token), + InsertionMode::AfterHead => self.handle_after_head(token), + InsertionMode::InBody => self.handle_in_body(token), + InsertionMode::Text => self.handle_text(token), + InsertionMode::InTable => self.handle_in_table(token), + InsertionMode::InTableText => self.handle_in_table_text(token), + InsertionMode::InCaption => self.handle_in_caption(token), + InsertionMode::InColumnGroup => self.handle_in_column_group(token), + InsertionMode::InTableBody => self.handle_in_table_body(token), + InsertionMode::InRow => self.handle_in_row(token), + InsertionMode::InCell => self.handle_in_cell(token), + InsertionMode::InSelect => self.handle_in_select(token), + InsertionMode::InSelectInTable => self.handle_in_select_in_table(token), + InsertionMode::InTemplate => self.handle_in_template(token), + InsertionMode::AfterBody => self.handle_after_body(token), + InsertionMode::InFrameset => self.handle_in_frameset(token), + InsertionMode::AfterFrameset => self.handle_after_frameset(token), + InsertionMode::AfterAfterBody => self.handle_after_after_body(token), + InsertionMode::AfterAfterFrameset => self.handle_after_after_frameset(token), + } + } + + // ----------------------------------------------------------------------- + // Foreign content dispatcher (WHATWG §13.2.6) + // ----------------------------------------------------------------------- + + fn is_mathml_text_integration_point(entry: &StackEntry) -> bool { + entry.ns == Namespace::MathMl + && matches!(entry.name.as_str(), "mi" | "mo" | "mn" | "ms" | "mtext") + } + + fn is_html_integration_point(entry: &StackEntry) -> bool { + if entry.ns == Namespace::Svg + && matches!(entry.name.as_str(), "foreignObject" | "desc" | "title") + { + return true; + } + // MathML annotation-xml with encoding text/html or application/xhtml+xml + entry.is_html_integration + } + + fn should_use_foreign_content_rules(&self, token: &Token) -> bool { + let Some(cur) = self.open_elements.last() else { + return false; + }; + + // If adjusted current node is in HTML namespace, use normal rules. + if cur.ns == Namespace::Html { + return false; + } + + // MathML text integration point: start tags (except mglyph/malignmark) + // and character tokens use normal rules. + if Self::is_mathml_text_integration_point(cur) { + match token { + Token::StartTag { name, .. } if name != "mglyph" && name != "malignmark" => { + return false; + } + Token::Character(_) => return false, + _ => {} + } + } + + // MathML annotation-xml + start tag "svg" → normal rules + if cur.ns == Namespace::MathMl && cur.name == "annotation-xml" { + if let Token::StartTag { name, .. } = token { + if name == "svg" { + return false; + } + } + } + + // HTML integration point: start tags and character tokens use normal rules. + if Self::is_html_integration_point(cur) { + match token { + Token::StartTag { .. } | Token::Character(_) => return false, + _ => {} + } + } + + // EOF always uses normal rules. + if *token == Token::Eof { + return false; + } + + true + } + + #[allow(clippy::too_many_lines)] + fn handle_foreign_content(&mut self, token: Token) { + match token { + Token::Character('\0') => { + self.insert_character('\u{FFFD}'); + } + Token::Character(c) if is_ascii_whitespace(c) => { + self.insert_character(c); + } + Token::Character(c) => { + self.insert_character(c); + self.frameset_ok = false; + } + Token::Comment(data) => { + self.insert_comment(&data); + } + Token::Doctype { .. } | Token::Eof => { + // Parse error, ignore. + } + Token::StartTag { + ref name, + ref attributes, + .. + } if is_foreign_breakout_tag(name) + || (name == "font" + && attributes + .iter() + .any(|a| matches!(a.name.as_str(), "color" | "face" | "size"))) => + { + // Parse error. Pop until MathML text integration point, + // HTML integration point, or HTML namespace element. + while let Some(top) = self.open_elements.last() { + if top.ns == Namespace::Html + || Self::is_mathml_text_integration_point(top) + || Self::is_html_integration_point(top) + { + break; + } + // In fragment mode, don't pop the context element. + if self.open_elements.len() <= 2 + && self + .fragment_context + .as_ref() + .is_some_and(|(_, ns)| *ns != Namespace::Html) + { + break; + } + self.open_elements.pop(); + } + // Use dispatch_to_current_mode to avoid re-entering + // foreign content handling when the context is foreign. + self.dispatch_to_current_mode(token); + } + Token::StartTag { + name, + attributes, + self_closing, + } => { + // Any other start tag in foreign content. + let cur_ns = self.open_elements.last().map_or(Namespace::Html, |e| e.ns); + + let (adjusted_name, adjusted_attrs, ns) = match cur_ns { + Namespace::MathMl => { + let attrs: Vec<tokenizer::Attribute> = attributes + .iter() + .map(|a| tokenizer::Attribute { + name: adjust_mathml_attributes(&a.name).to_string(), + value: a.value.clone(), + }) + .collect(); + (name, attrs, Namespace::MathMl) + } + Namespace::Svg => { + let tag = adjust_svg_tag_name(&name); + let attrs: Vec<tokenizer::Attribute> = attributes + .iter() + .map(|a| tokenizer::Attribute { + name: adjust_svg_attributes(&a.name).to_string(), + value: a.value.clone(), + }) + .collect(); + (tag, attrs, Namespace::Svg) + } + Namespace::Html => (name, attributes, Namespace::Html), + }; + + self.insert_foreign_element(&adjusted_name, &adjusted_attrs, ns); + + if self_closing { + self.open_elements.pop(); + } + } + Token::EndTag { ref name } if name == "br" || name == "p" => { + // Per spec §13.2.6.5: parse error. Pop until we reach an + // HTML namespace element, MathML text integration point, or + // HTML integration point; then reprocess as "in body". + // In fragment mode, never pop below the context element. + let min_stack = if self + .fragment_context + .as_ref() + .is_some_and(|(_, ns)| *ns != Namespace::Html) + { + 2 + } else { + 1 + }; + while self.open_elements.len() > min_stack { + let Some(top) = self.open_elements.last() else { + break; + }; + if top.ns == Namespace::Html + || Self::is_mathml_text_integration_point(top) + || Self::is_html_integration_point(top) + { + break; + } + self.open_elements.pop(); + } + self.dispatch_to_current_mode(token); + } + Token::EndTag { name } => { + // Any other end tag in foreign content. + self.handle_foreign_end_tag(&name); + } + } + } + + fn handle_foreign_end_tag(&mut self, tag_name: &str) { + if self.open_elements.is_empty() { + return; + } + + // In fragment parsing, never pop below the initial stack + // (html element, or html + context element for foreign contexts). + let min_idx = if self.fragment_context.is_some() { + // html is at 0; for foreign contexts the context element is at 1. + if self + .fragment_context + .as_ref() + .is_some_and(|(_, ns)| *ns != Namespace::Html) + { + 2 + } else { + 1 + } + } else { + 0 + }; + + let mut node_idx = self.open_elements.len() - 1; + + loop { + let node = &self.open_elements[node_idx]; + + if node.ns == Namespace::Html { + // Process using the rules for the current insertion mode + // (not process_token, to avoid re-entering foreign content). + self.dispatch_to_current_mode(Token::EndTag { + name: tag_name.to_string(), + }); + return; + } + + if node.name.eq_ignore_ascii_case(tag_name) { + // Don't pop the context element or below it. + let pop_to = node_idx.max(min_idx); + while self.open_elements.len() > pop_to { + self.open_elements.pop(); + } + return; + } + + if node_idx <= min_idx { + return; + } + node_idx -= 1; + } + } + + /// Dispatch a token directly to the current insertion mode handler, + /// bypassing the foreign content check. + fn dispatch_to_current_mode(&mut self, token: Token) { + match self.mode { + InsertionMode::Initial => self.handle_initial(token), + InsertionMode::BeforeHtml => self.handle_before_html(token), + InsertionMode::BeforeHead => self.handle_before_head(token), + InsertionMode::InHead => self.handle_in_head(token), + InsertionMode::InHeadNoscript => self.handle_in_head_noscript(token), + InsertionMode::AfterHead => self.handle_after_head(token), + InsertionMode::InBody => self.handle_in_body(token), + InsertionMode::Text => self.handle_text(token), + InsertionMode::InTable => self.handle_in_table(token), + InsertionMode::InTableText => self.handle_in_table_text(token), + InsertionMode::InCaption => self.handle_in_caption(token), + InsertionMode::InColumnGroup => self.handle_in_column_group(token), + InsertionMode::InTableBody => self.handle_in_table_body(token), + InsertionMode::InRow => self.handle_in_row(token), + InsertionMode::InCell => self.handle_in_cell(token), + InsertionMode::InSelect => self.handle_in_select(token), + InsertionMode::InSelectInTable => self.handle_in_select_in_table(token), + InsertionMode::InTemplate => self.handle_in_template(token), + InsertionMode::AfterBody => self.handle_after_body(token), + InsertionMode::InFrameset => self.handle_in_frameset(token), + InsertionMode::AfterFrameset => self.handle_after_frameset(token), + InsertionMode::AfterAfterBody => self.handle_after_after_body(token), + InsertionMode::AfterAfterFrameset => self.handle_after_after_frameset(token), + } + } + + fn insert_foreign_element( + &mut self, + name: &str, + attrs: &[tokenizer::Attribute], + ns: Namespace, + ) -> NodeId { + let tree_attrs: Vec<crate::tree::Attribute> = attrs + .iter() + .map(|a| { + let (prefix, local_name, attr_ns) = parse_foreign_attr(&a.name); + crate::tree::Attribute { + name: local_name.to_string(), + value: a.value.clone(), + prefix: prefix.map(String::from), + namespace: attr_ns.map(String::from), + raw_value: None, + } + }) + .collect(); + + let namespace = if ns == Namespace::Html { + None + } else { + Some(ns.uri().to_string()) + }; + + let id_value = tree_attrs.iter().find_map(|a| { + if a.name == "id" { + Some(a.value.clone()) + } else { + None + } + }); + + let node_id = self.doc.create_node(NodeKind::Element { + name: name.to_string(), + prefix: None, + namespace, + attributes: tree_attrs, + }); + + if let Some(id_val) = id_value { + self.doc.set_id(&id_val, node_id); + } + + // Detect HTML integration point: MathML annotation-xml with + // encoding="text/html" or "application/xhtml+xml". + let is_html_integration = ns == Namespace::MathMl + && name == "annotation-xml" + && attrs.iter().any(|a| { + a.name.eq_ignore_ascii_case("encoding") + && (a.value.eq_ignore_ascii_case("text/html") + || a.value.eq_ignore_ascii_case("application/xhtml+xml")) + }); + + let (parent, before) = self.appropriate_insertion_point(); + self.insert_node_at(node_id, parent, before); + self.open_elements.push(StackEntry { + node_id, + name: name.to_string(), + ns, + is_html_integration, + }); + node_id + } + + // ----------------------------------------------------------------------- + // Stack / scope helpers + // ----------------------------------------------------------------------- + + fn current_node(&self) -> Option<NodeId> { + self.open_elements.last().map(|e| e.node_id) + } + + fn current_node_name(&self) -> &str { + self.open_elements.last().map_or("", |e| e.name.as_str()) + } + + fn element_in_scope_impl(&self, target: &str, scope_fn: fn(&str, Namespace) -> bool) -> bool { + for entry in self.open_elements.iter().rev() { + if entry.name == target && entry.ns == Namespace::Html { + return true; + } + if scope_fn(&entry.name, entry.ns) { + return false; + } + } + false + } + + fn element_in_scope(&self, target: &str) -> bool { + self.element_in_scope_impl(target, is_scope_element) + } + + fn element_in_list_item_scope(&self, target: &str) -> bool { + self.element_in_scope_impl(target, is_list_item_scope_element) + } + + fn element_in_button_scope(&self, target: &str) -> bool { + self.element_in_scope_impl(target, is_button_scope_element) + } + + fn element_in_table_scope(&self, target: &str) -> bool { + self.element_in_scope_impl(target, is_table_scope_element) + } + + fn element_in_select_scope(&self, target: &str) -> bool { + self.element_in_scope_impl(target, is_select_scope_element) + } + + // ----------------------------------------------------------------------- + // Insertion helpers + // ----------------------------------------------------------------------- + + fn appropriate_insertion_point(&self) -> (NodeId, Option<NodeId>) { + self.appropriate_insertion_point_with_override(None) + } + + fn appropriate_insertion_point_with_override( + &self, + override_target: Option<NodeId>, + ) -> (NodeId, Option<NodeId>) { + let target = override_target + .unwrap_or_else(|| self.current_node().unwrap_or_else(|| self.doc.root())); + + // Per WHATWG spec §13.2.6.1: foster parenting only applies when the + // target is a table, tbody, tfoot, thead, or tr element. + if self.foster_parenting { + let target_name = self + .open_elements + .iter() + .find(|e| e.node_id == target) + .map_or("", |e| e.name.as_str()); + if matches!(target_name, "table" | "tbody" | "tfoot" | "thead" | "tr") { + // Find the last table and last template in the stack. + let mut last_table: Option<usize> = None; + let mut last_template: Option<usize> = None; + for i in (0..self.open_elements.len()).rev() { + if self.open_elements[i].name == "table" + && self.open_elements[i].ns == Namespace::Html + && last_table.is_none() + { + last_table = Some(i); + } + if self.open_elements[i].name == "template" + && self.open_elements[i].ns == Namespace::Html + && last_template.is_none() + { + last_template = Some(i); + } + } + + // If template comes after table (or no table), insert + // inside the template element (its content). + if let Some(tmpl_idx) = last_template { + if last_table.is_none() || tmpl_idx > last_table.unwrap_or(0) { + return (self.open_elements[tmpl_idx].node_id, None); + } + } + + if let Some(table_idx) = last_table { + if let Some(parent) = self.doc.parent(self.open_elements[table_idx].node_id) { + return (parent, Some(self.open_elements[table_idx].node_id)); + } + // If table has no parent, use the element before it in the stack + if table_idx > 0 { + return (self.open_elements[table_idx - 1].node_id, None); + } + } + + // No table or template — fall back to first element + if !self.open_elements.is_empty() { + return (self.open_elements[0].node_id, None); + } + } + } + (target, None) + } + + fn insert_node_at(&mut self, node_id: NodeId, parent: NodeId, before: Option<NodeId>) { + if let Some(ref_node) = before { + self.doc.insert_before(ref_node, node_id); + } else { + self.doc.append_child(parent, node_id); + } + } + + fn create_element_for_token( + &mut self, + name: &str, + attrs: &[tokenizer::Attribute], + ns: Namespace, + ) -> NodeId { + let tree_attrs: Vec<crate::tree::Attribute> = attrs + .iter() + .map(|a| crate::tree::Attribute { + name: a.name.clone(), + value: a.value.clone(), + prefix: None, + namespace: None, + raw_value: None, + }) + .collect(); + + let namespace = if ns == Namespace::Html { + None + } else { + Some(ns.uri().to_string()) + }; + + self.doc.create_node(NodeKind::Element { + name: name.to_string(), + prefix: None, + namespace, + attributes: tree_attrs, + }) + } + + fn insert_element( + &mut self, + name: &str, + attrs: &[tokenizer::Attribute], + ns: Namespace, + ) -> NodeId { + let node_id = self.create_element_for_token(name, attrs, ns); + let (parent, before) = self.appropriate_insertion_point(); + self.insert_node_at(node_id, parent, before); + self.open_elements.push(StackEntry { + node_id, + name: name.to_string(), + ns, + is_html_integration: false, + }); + node_id + } + + fn insert_html_element(&mut self, name: &str, attrs: &[tokenizer::Attribute]) -> NodeId { + self.insert_element(name, attrs, Namespace::Html) + } + + fn insert_character(&mut self, c: char) { + let (parent, before) = self.appropriate_insertion_point(); + + // Try to append to existing text node — either the last child of + // parent (normal case) or the previous sibling of the reference node + // (foster-parenting case). + let adjacent_text = if let Some(ref_node) = before { + self.doc.prev_sibling(ref_node) + } else { + self.doc.last_child(parent) + }; + if let Some(text_node) = adjacent_text { + if let NodeKind::Text { ref mut content } = &mut self.doc.node_mut(text_node).kind { + content.push(c); + return; + } + } + + let text_id = self.doc.create_node(NodeKind::Text { + content: c.to_string(), + }); + self.insert_node_at(text_id, parent, before); + } + + /// Append a string of characters to the current insertion point. + /// + /// This is an optimization over calling `insert_character` per-char: + /// it computes the insertion point once and appends the whole string. + fn insert_characters(&mut self, s: &str) { + let (parent, before) = self.appropriate_insertion_point(); + let adjacent_text = if let Some(ref_node) = before { + self.doc.prev_sibling(ref_node) + } else { + self.doc.last_child(parent) + }; + if let Some(text_node) = adjacent_text { + if let NodeKind::Text { ref mut content } = &mut self.doc.node_mut(text_node).kind { + content.push_str(s); + return; + } + } + let text_id = self.doc.create_node(NodeKind::Text { + content: s.to_string(), + }); + self.insert_node_at(text_id, parent, before); + } + + fn insert_comment(&mut self, data: &str) { + let (parent, before) = self.appropriate_insertion_point(); + let comment_id = self.doc.create_node(NodeKind::Comment { + content: data.to_string(), + }); + self.insert_node_at(comment_id, parent, before); + } + + fn insert_comment_at_document(&mut self, data: &str) { + let doc_root = self.doc.root(); + let comment_id = self.doc.create_node(NodeKind::Comment { + content: data.to_string(), + }); + self.doc.append_child(doc_root, comment_id); + } + + // ----------------------------------------------------------------------- + // Implied end tags + // ----------------------------------------------------------------------- + + fn generate_implied_end_tags(&mut self, exclude: Option<&str>) { + loop { + let name = self.current_node_name().to_string(); + if matches!( + name.as_str(), + "dd" | "dt" | "li" | "optgroup" | "option" | "p" | "rb" | "rp" | "rt" | "rtc" + ) && exclude.map_or(true, |ex| ex != name) + { + self.open_elements.pop(); + } else { + break; + } + } + } + + fn generate_all_implied_end_tags(&mut self) { + loop { + let name = self.current_node_name().to_string(); + if matches!( + name.as_str(), + "dd" | "dt" + | "li" + | "optgroup" + | "option" + | "p" + | "rb" + | "rp" + | "rt" + | "rtc" + | "tbody" + | "td" + | "tfoot" + | "th" + | "thead" + | "tr" + | "caption" + | "colgroup" + ) { + self.open_elements.pop(); + } else { + break; + } + } + } + + fn close_p_element(&mut self) { + self.generate_implied_end_tags(Some("p")); + // Pop until p + while let Some(entry) = self.open_elements.pop() { + if entry.name == "p" { + break; + } + } + } + + // ----------------------------------------------------------------------- + // Active formatting list + // ----------------------------------------------------------------------- + + fn push_formatting(&mut self, node_id: NodeId, name: &str, attrs: &[tokenizer::Attribute]) { + // Noah's Ark clause: if there are already 3 entries with the same + // tag name and attributes before the last marker, remove the earliest. + let mut count = 0; + let mut earliest_idx = None; + for (i, entry) in self.active_formatting.iter().enumerate().rev() { + match entry { + FormatEntry::Marker => break, + FormatEntry::Element { + name: n, attrs: a, .. + } if n == name && a == attrs => { + count += 1; + earliest_idx = Some(i); + } + FormatEntry::Element { .. } => {} + } + } + if count >= 3 { + if let Some(idx) = earliest_idx { + self.active_formatting.remove(idx); + } + } + self.active_formatting.push(FormatEntry::Element { + node_id, + name: name.to_string(), + attrs: attrs.to_vec(), + }); + } + + fn push_formatting_marker(&mut self) { + self.active_formatting.push(FormatEntry::Marker); + } + + fn clear_formatting_to_marker(&mut self) { + while let Some(entry) = self.active_formatting.pop() { + if matches!(entry, FormatEntry::Marker) { + break; + } + } + } + + fn reconstruct_formatting(&mut self) { + if self.active_formatting.is_empty() { + return; + } + + // If the last entry is a marker or already on the stack, nothing to do. + if let Some(last) = self.active_formatting.last() { + match last { + FormatEntry::Marker => return, + FormatEntry::Element { node_id, .. } => { + if self.open_elements.iter().any(|e| e.node_id == *node_id) { + return; + } + } + } + } + + // Walk backwards to find the first entry that IS on the stack or is a marker. + let mut i = self.active_formatting.len() - 1; + loop { + if i == 0 { + break; + } + i -= 1; + match &self.active_formatting[i] { + FormatEntry::Marker => { + i += 1; + break; + } + FormatEntry::Element { node_id, .. } => { + if self.open_elements.iter().any(|e| e.node_id == *node_id) { + i += 1; + break; + } + } + } + } + + // Now walk forward from i, creating new elements. + while i < self.active_formatting.len() { + let (name, attrs) = match &self.active_formatting[i] { + FormatEntry::Element { name, attrs, .. } => (name.clone(), attrs.clone()), + FormatEntry::Marker => { + i += 1; + continue; + } + }; + + let new_id = self.insert_html_element(&name, &attrs); + self.active_formatting[i] = FormatEntry::Element { + node_id: new_id, + name, + attrs, + }; + i += 1; + } + } + + // ----------------------------------------------------------------------- + // Adoption agency algorithm (WHATWG 13.2.6.4.7) + // ----------------------------------------------------------------------- + + #[allow(clippy::too_many_lines)] + fn adoption_agency(&mut self, tag_name: &str) { + // Step 1: If current node is an HTML element with tag name equal to + // the token's tag name, and the current node is not in the active + // formatting list, just pop it. + if let Some(cur) = self.open_elements.last() { + if cur.name == tag_name && cur.ns == Namespace::Html { + let cur_id = cur.node_id; + let in_formatting = self.active_formatting.iter().any( + |e| matches!(e, FormatEntry::Element { node_id, .. } if *node_id == cur_id), + ); + if !in_formatting { + self.open_elements.pop(); + return; + } + } + } + + // Outer loop (max 8 iterations) + for _ in 0..8 { + // Step 4: Find the formatting element — the last entry in the + // active formatting list that has tag name equal to tag_name and + // that is before the last marker (or the start of the list). + let fmt_idx = { + let mut found = None; + for (i, entry) in self.active_formatting.iter().enumerate().rev() { + match entry { + FormatEntry::Marker => break, + FormatEntry::Element { name, .. } if name == tag_name => { + found = Some(i); + break; + } + FormatEntry::Element { .. } => {} + } + } + found + }; + + let Some(fmt_idx) = fmt_idx else { + // No formatting element found; process as "any other end tag". + self.handle_any_other_end_tag(tag_name); + return; + }; + + let FormatEntry::Element { + node_id: fmt_node_id, + name: ref fmt_name, + attrs: ref fmt_attrs, + } = self.active_formatting[fmt_idx] + else { + return; + }; + let fmt_name = fmt_name.clone(); + let fmt_attrs = fmt_attrs.clone(); + + // Step 5: If the formatting element is not on the stack of open + // elements, remove it from the formatting list and return. + let Some(stack_idx) = self + .open_elements + .iter() + .position(|e| e.node_id == fmt_node_id) + else { + self.active_formatting.remove(fmt_idx); + return; + }; + + // Step 6: If the formatting element is not in scope, return. + if !self.element_in_scope(&fmt_name) { + return; + } + + // Step 8: Find the furthest block. + let furthest_block_idx = self.open_elements[stack_idx + 1..] + .iter() + .position(|e| is_special_element_ns(&e.name, e.ns)) + .map(|i| i + stack_idx + 1); + + // Step 9: No furthest block → pop to formatting element. + let Some(furthest_block_idx) = furthest_block_idx else { + while self.open_elements.len() > stack_idx { + self.open_elements.pop(); + } + self.active_formatting.remove(fmt_idx); + return; + }; + + // We need to track the furthest block by node_id since indices shift. + let furthest_block_node_id = self.open_elements[furthest_block_idx].node_id; + + // Step 10-11 + let common_ancestor = self.open_elements[stack_idx - 1].node_id; + let mut bookmark = fmt_idx; + + // Step 12: inner loop + let mut node_stack_idx = furthest_block_idx; + let mut last_node_id = furthest_block_node_id; + let mut inner_counter = 0u32; + + loop { + inner_counter += 1; + + // Step 12.2: node = element immediately above node in stack + if node_stack_idx == 0 { + break; + } + node_stack_idx -= 1; + if node_stack_idx <= stack_idx { + break; + } + + let node_id = self.open_elements[node_stack_idx].node_id; + + // Step 12.3: Check if node is in the active formatting list + let fmt_list_idx = self.active_formatting.iter().position( + |e| matches!(e, FormatEntry::Element { node_id: nid, .. } if *nid == node_id), + ); + + // Step 12.4: If not in formatting list, remove from stack + let Some(fmt_list_idx) = fmt_list_idx else { + self.open_elements.remove(node_stack_idx); + continue; + }; + + // Step 12.5: If inner counter > 3, remove from formatting list + if inner_counter > 3 { + self.active_formatting.remove(fmt_list_idx); + if bookmark > fmt_list_idx { + bookmark -= 1; + } + self.open_elements.remove(node_stack_idx); + continue; + } + + // Step 12.6-7: Create replacement element + let (old_name, old_attrs) = match &self.active_formatting[fmt_list_idx] { + FormatEntry::Element { name, attrs, .. } => (name.clone(), attrs.clone()), + FormatEntry::Marker => continue, + }; + + let new_element = + self.create_element_for_token(&old_name, &old_attrs, Namespace::Html); + + self.active_formatting[fmt_list_idx] = FormatEntry::Element { + node_id: new_element, + name: old_name.clone(), + attrs: old_attrs, + }; + self.open_elements[node_stack_idx] = StackEntry { + node_id: new_element, + name: old_name, + ns: Namespace::Html, + is_html_integration: false, + }; + + // Step 12.8: If last node was the furthest block, move bookmark + if last_node_id == furthest_block_node_id { + bookmark = fmt_list_idx + 1; + } + + // Step 12.9: Move last_node to be a child of new_element + self.doc.detach(last_node_id); + self.doc.append_child(new_element, last_node_id); + last_node_id = new_element; + } + + // Step 13: insert last_node at the appropriate place + self.doc.detach(last_node_id); + // Use the appropriate insertion point with common ancestor as the + // override target. This correctly handles foster parenting when + // the common ancestor is a table-related element. + let (parent, before) = + self.appropriate_insertion_point_with_override(Some(common_ancestor)); + self.insert_node_at(last_node_id, parent, before); + + // Step 14: create a new element for the formatting element + let new_fmt = self.create_element_for_token(&fmt_name, &fmt_attrs, Namespace::Html); + + // Step 15: move children of the furthest block to the new element + let fb_id = self + .open_elements + .iter() + .find(|e| e.node_id == furthest_block_node_id) + .map(|e| e.node_id); + if let Some(fb_id) = fb_id { + let children: Vec<NodeId> = self.doc.children(fb_id).collect(); + for child in children { + self.doc.detach(child); + self.doc.append_child(new_fmt, child); + } + // Step 16: append new element to the furthest block + self.doc.append_child(fb_id, new_fmt); + } + + // Step 17: remove old formatting element, insert new at bookmark + if let Some(old_pos) = self.active_formatting.iter().position( + |e| matches!(e, FormatEntry::Element { node_id, .. } if *node_id == fmt_node_id), + ) { + self.active_formatting.remove(old_pos); + if bookmark > old_pos { + bookmark -= 1; + } + } + let bookmark = bookmark.min(self.active_formatting.len()); + self.active_formatting.insert( + bookmark, + FormatEntry::Element { + node_id: new_fmt, + name: fmt_name.clone(), + attrs: fmt_attrs.clone(), + }, + ); + + // Step 18: remove old from stack, insert new after furthest block + if let Some(old_pos) = self + .open_elements + .iter() + .position(|e| e.node_id == fmt_node_id) + { + self.open_elements.remove(old_pos); + } + if let Some(fb_pos) = + fb_id.and_then(|fb| self.open_elements.iter().position(|e| e.node_id == fb)) + { + let insert_pos = (fb_pos + 1).min(self.open_elements.len()); + self.open_elements.insert( + insert_pos, + StackEntry { + node_id: new_fmt, + name: fmt_name, + ns: Namespace::Html, + is_html_integration: false, + }, + ); + } + } + } + + // ----------------------------------------------------------------------- + // Tokenizer state switching for raw text elements + // ----------------------------------------------------------------------- + + #[allow(dead_code)] + fn switch_tokenizer_for_raw(&mut self, name: &str) { + match name { + "script" => self.tokenizer.set_state(State::ScriptData), + "style" | "noframes" | "noembed" | "noscript" => { + self.tokenizer.set_state(State::RawText); + } + "textarea" | "title" => { + self.tokenizer.set_state(State::RcData); + } + "plaintext" => self.tokenizer.set_state(State::Plaintext), + _ => {} + } + self.tokenizer.set_last_start_tag(name); + } + + fn parse_raw_text(&mut self, name: &str, attrs: &[tokenizer::Attribute]) { + self.insert_html_element(name, attrs); + self.tokenizer.set_state(State::RawText); + self.tokenizer.set_last_start_tag(name); + self.original_mode = self.mode; + self.mode = InsertionMode::Text; + } + + fn parse_rcdata(&mut self, name: &str, attrs: &[tokenizer::Attribute]) { + self.insert_html_element(name, attrs); + self.tokenizer.set_state(State::RcData); + self.tokenizer.set_last_start_tag(name); + self.original_mode = self.mode; + self.mode = InsertionMode::Text; + } + + // ----------------------------------------------------------------------- + // Insertion mode handlers + // ----------------------------------------------------------------------- + + fn handle_initial(&mut self, token: Token) { + match token { + Token::Character(c) if is_ascii_whitespace(c) => { + // Ignore + } + Token::Comment(data) => { + self.insert_comment_at_document(&data); + } + Token::Doctype { + name, + public_id, + system_id, + force_quirks, + } => { + let doctype_name = name.unwrap_or_default(); + self.quirks_mode = determine_quirks_mode( + &doctype_name, + public_id.as_deref(), + system_id.as_deref(), + force_quirks, + ); + let doctype_id = self.doc.create_node(NodeKind::DocumentType { + name: doctype_name, + public_id, + system_id, + internal_subset: None, + }); + let root = self.doc.root(); + self.doc.append_child(root, doctype_id); + self.mode = InsertionMode::BeforeHtml; + } + _ => { + // Missing DOCTYPE → quirks mode. + self.quirks_mode = QuirksMode::Quirks; + self.mode = InsertionMode::BeforeHtml; + self.process_token(token); + } + } + } + + fn handle_before_html(&mut self, token: Token) { + match token { + Token::Comment(data) => { + self.insert_comment_at_document(&data); + } + Token::Doctype { .. } => { /* ignore */ } + Token::Character(c) if is_ascii_whitespace(c) => { /* ignore */ } + Token::StartTag { ref name, .. } if name == "html" => { + if let Token::StartTag { + name, attributes, .. + } = token + { + let node_id = + self.create_element_for_token(&name, &attributes, Namespace::Html); + let root = self.doc.root(); + self.doc.append_child(root, node_id); + self.open_elements.push(StackEntry { + node_id, + name, + ns: Namespace::Html, + is_html_integration: false, + }); + self.mode = InsertionMode::BeforeHead; + } + } + Token::EndTag { ref name } + if !matches!(name.as_str(), "head" | "body" | "html" | "br") => + { + // Parse error, ignore + } + _ => { + let node_id = self.create_element_for_token("html", &[], Namespace::Html); + let root = self.doc.root(); + self.doc.append_child(root, node_id); + self.open_elements.push(StackEntry { + node_id, + name: "html".to_string(), + ns: Namespace::Html, + is_html_integration: false, + }); + self.mode = InsertionMode::BeforeHead; + self.process_token(token); + } + } + } + + fn handle_before_head(&mut self, token: Token) { + match token { + Token::Character(c) if is_ascii_whitespace(c) => { /* ignore */ } + Token::Comment(data) => self.insert_comment(&data), + Token::Doctype { .. } => { /* ignore */ } + Token::StartTag { ref name, .. } if name == "html" => { + self.handle_in_body(token); + } + Token::StartTag { ref name, .. } if name == "head" => { + if let Token::StartTag { + name, attributes, .. + } = token + { + let node_id = self.insert_html_element(&name, &attributes); + self.head_pointer = Some(node_id); + self.mode = InsertionMode::InHead; + } + } + Token::EndTag { ref name } + if !matches!(name.as_str(), "head" | "body" | "html" | "br") => + { + // Parse error, ignore + } + _ => { + let node_id = self.insert_html_element("head", &[]); + self.head_pointer = Some(node_id); + self.mode = InsertionMode::InHead; + self.process_token(token); + } + } + } + + #[allow(clippy::too_many_lines, clippy::match_same_arms)] + fn handle_in_head(&mut self, token: Token) { + match token { + Token::Character(c) if is_ascii_whitespace(c) => { + self.insert_character(c); + } + Token::Comment(data) => self.insert_comment(&data), + Token::Doctype { .. } => { /* ignore */ } + Token::StartTag { ref name, .. } if name == "html" => { + self.handle_in_body(token); + } + Token::StartTag { ref name, .. } + if matches!( + name.as_str(), + "base" | "basefont" | "bgsound" | "link" | "meta" + ) => + { + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.open_elements.pop(); // void + } + } + Token::StartTag { ref name, .. } if name == "title" => { + if let Token::StartTag { + name, attributes, .. + } = token + { + self.parse_rcdata(&name, &attributes); + } + } + Token::StartTag { ref name, .. } if name == "noscript" && self.scripting => { + if let Token::StartTag { + name, attributes, .. + } = token + { + self.parse_raw_text(&name, &attributes); + } + } + Token::StartTag { ref name, .. } if matches!(name.as_str(), "noframes" | "style") => { + if let Token::StartTag { + name, attributes, .. + } = token + { + self.parse_raw_text(&name, &attributes); + } + } + Token::StartTag { ref name, .. } if name == "noscript" => { + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.mode = InsertionMode::InHeadNoscript; + } + } + Token::StartTag { ref name, .. } if name == "script" => { + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.tokenizer.set_state(State::ScriptData); + self.tokenizer.set_last_start_tag(&name); + self.original_mode = self.mode; + self.mode = InsertionMode::Text; + } + } + Token::EndTag { ref name } if name == "head" => { + self.open_elements.pop(); + self.mode = InsertionMode::AfterHead; + } + Token::EndTag { ref name } if matches!(name.as_str(), "body" | "html" | "br") => { + self.open_elements.pop(); + self.mode = InsertionMode::AfterHead; + self.process_token(token); + } + Token::StartTag { ref name, .. } if name == "template" => { + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.push_formatting_marker(); + self.frameset_ok = false; + self.mode = InsertionMode::InTemplate; + self.template_modes.push(InsertionMode::InTemplate); + } + } + Token::EndTag { ref name } if name == "template" => { + if self + .open_elements + .iter() + .any(|e| e.name == "template" && e.ns == Namespace::Html) + { + self.generate_all_implied_end_tags(); + while let Some(entry) = self.open_elements.pop() { + if entry.name == "template" && entry.ns == Namespace::Html { + break; + } + } + self.clear_formatting_to_marker(); + self.template_modes.pop(); + self.reset_insertion_mode(); + } + } + Token::StartTag { ref name, .. } if name == "head" => { + // Parse error, ignore + } + Token::EndTag { .. } => { + // Parse error, ignore + } + _ => { + self.open_elements.pop(); + self.mode = InsertionMode::AfterHead; + self.process_token(token); + } + } + } + + fn handle_in_head_noscript(&mut self, token: Token) { + match token { + Token::Doctype { .. } => { /* ignore */ } + Token::StartTag { ref name, .. } if name == "html" => { + self.handle_in_body(token); + } + Token::EndTag { ref name } if name == "noscript" => { + self.open_elements.pop(); + self.mode = InsertionMode::InHead; + } + Token::Character(c) if is_ascii_whitespace(c) => { + self.handle_in_head(token); + } + Token::Comment(_) => { + self.handle_in_head(token); + } + Token::StartTag { ref name, .. } + if matches!( + name.as_str(), + "basefont" | "bgsound" | "link" | "meta" | "noframes" | "style" + ) => + { + self.handle_in_head(token); + } + Token::StartTag { ref name, .. } if matches!(name.as_str(), "head" | "noscript") => { + // Parse error, ignore + } + Token::EndTag { ref name } if name != "br" => { + // Parse error, ignore + } + _ => { + self.open_elements.pop(); + self.mode = InsertionMode::InHead; + self.process_token(token); + } + } + } + + fn handle_after_head(&mut self, token: Token) { + match token { + Token::Character(c) if is_ascii_whitespace(c) => { + self.insert_character(c); + } + Token::Comment(data) => self.insert_comment(&data), + Token::Doctype { .. } => { /* ignore */ } + Token::StartTag { ref name, .. } if name == "html" => { + self.handle_in_body(token); + } + Token::StartTag { ref name, .. } if name == "body" => { + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.frameset_ok = false; + self.mode = InsertionMode::InBody; + } + } + Token::StartTag { ref name, .. } if name == "frameset" => { + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.mode = InsertionMode::InFrameset; + } + } + Token::StartTag { ref name, .. } + if matches!( + name.as_str(), + "base" + | "basefont" + | "bgsound" + | "link" + | "meta" + | "noframes" + | "script" + | "style" + | "template" + | "title" + ) => + { + // Push head back, process in InHead, then remove head again + if let Some(head) = self.head_pointer { + self.open_elements.push(StackEntry { + node_id: head, + name: "head".to_string(), + ns: Namespace::Html, + is_html_integration: false, + }); + } + self.handle_in_head(token); + // Remove head from stack if still there + if let Some(pos) = self.open_elements.iter().position(|e| e.name == "head") { + self.open_elements.remove(pos); + } + } + Token::EndTag { ref name } if name == "template" => { + self.handle_in_head(token); + } + Token::StartTag { ref name, .. } if name == "head" => { + // Parse error, ignore + } + Token::EndTag { ref name } if !matches!(name.as_str(), "body" | "html" | "br") => { + // Parse error, ignore + } + _ => { + self.insert_html_element("body", &[]); + self.mode = InsertionMode::InBody; + self.process_token(token); + } + } + } + + #[allow( + clippy::too_many_lines, + clippy::cognitive_complexity, + clippy::match_same_arms + )] + fn handle_in_body(&mut self, token: Token) { + match token { + Token::Character('\0') => { /* ignore */ } + Token::Character(c) if is_ascii_whitespace(c) => { + self.reconstruct_formatting(); + self.insert_character(c); + } + Token::Character(c) => { + self.reconstruct_formatting(); + self.insert_character(c); + self.frameset_ok = false; + } + Token::Comment(data) => self.insert_comment(&data), + Token::Doctype { .. } => { /* ignore */ } + Token::StartTag { ref name, .. } if name == "html" => { + // Merge attributes onto the existing html element — but + // ignore if there is a template on the stack. + if !self.open_elements.iter().any(|e| e.name == "template") { + if let Token::StartTag { attributes, .. } = token { + if let Some(html_entry) = self.open_elements.first() { + let html_id = html_entry.node_id; + for attr in &attributes { + if self.doc.attribute(html_id, &attr.name).is_none() { + if let NodeKind::Element { + ref mut attributes, .. + } = &mut self.doc.node_mut(html_id).kind + { + attributes.push(crate::tree::Attribute { + name: attr.name.clone(), + value: attr.value.clone(), + prefix: None, + namespace: None, + raw_value: None, + }); + } + } + } + } + } + } + } + Token::StartTag { ref name, .. } + if matches!( + name.as_str(), + "base" + | "basefont" + | "bgsound" + | "link" + | "meta" + | "noframes" + | "script" + | "style" + | "template" + | "title" + ) => + { + self.handle_in_head(token); + } + Token::EndTag { ref name } if name == "template" => { + self.handle_in_head(token); + } + Token::StartTag { ref name, .. } if name == "body" => { + // Merge attributes onto existing body — but ignore if there + // is a template element on the stack of open elements. + if let Token::StartTag { attributes, .. } = token { + if self.open_elements.len() >= 2 + && self.open_elements[1].name == "body" + && !self.open_elements.iter().any(|e| e.name == "template") + { + let body_id = self.open_elements[1].node_id; + self.frameset_ok = false; + for attr in &attributes { + if self.doc.attribute(body_id, &attr.name).is_none() { + if let NodeKind::Element { + ref mut attributes, .. + } = &mut self.doc.node_mut(body_id).kind + { + attributes.push(crate::tree::Attribute { + name: attr.name.clone(), + value: attr.value.clone(), + prefix: None, + namespace: None, + raw_value: None, + }); + } + } + } + } + } + } + Token::StartTag { ref name, .. } if name == "frameset" => { + // Ignore unless frameset_ok + if self.frameset_ok { + if let Token::StartTag { + name, attributes, .. + } = token + { + // Remove body from stack if present + if self.open_elements.len() >= 2 && self.open_elements[1].name == "body" { + let body_id = self.open_elements[1].node_id; + self.doc.detach(body_id); + while self.open_elements.len() > 1 { + self.open_elements.pop(); + } + } + self.insert_html_element(&name, &attributes); + self.mode = InsertionMode::InFrameset; + } + } + } + Token::Eof => { + if !self.template_modes.is_empty() { + self.handle_in_template(Token::Eof); + } + // Stop parsing + } + Token::EndTag { ref name } if name == "body" => { + if self.element_in_scope("body") { + self.mode = InsertionMode::AfterBody; + } + } + Token::EndTag { ref name } if name == "html" => { + if self.element_in_scope("body") { + self.mode = InsertionMode::AfterBody; + self.process_token(token); + } + } + Token::StartTag { ref name, .. } + if matches!( + name.as_str(), + "address" + | "article" + | "aside" + | "blockquote" + | "center" + | "details" + | "dialog" + | "dir" + | "div" + | "dl" + | "fieldset" + | "figcaption" + | "figure" + | "footer" + | "header" + | "hgroup" + | "main" + | "menu" + | "nav" + | "ol" + | "p" + | "search" + | "section" + | "summary" + | "ul" + ) => + { + if self.element_in_button_scope("p") { + self.close_p_element(); + } + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + } + } + Token::StartTag { ref name, .. } if is_heading(name) => { + if self.element_in_button_scope("p") { + self.close_p_element(); + } + if is_heading(self.current_node_name()) { + self.open_elements.pop(); + } + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + } + } + Token::StartTag { ref name, .. } if matches!(name.as_str(), "pre" | "listing") => { + if self.element_in_button_scope("p") { + self.close_p_element(); + } + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.skip_next_lf = true; + self.frameset_ok = false; + } + } + Token::StartTag { ref name, .. } if name == "form" => { + if self.form_pointer.is_some() + && !self.open_elements.iter().any(|e| e.name == "template") + { + // Parse error, ignore + } else { + if self.element_in_button_scope("p") { + self.close_p_element(); + } + if let Token::StartTag { + name, attributes, .. + } = token + { + let node_id = self.insert_html_element(&name, &attributes); + if !self.open_elements.iter().any(|e| e.name == "template") { + self.form_pointer = Some(node_id); + } + } + } + } + Token::StartTag { ref name, .. } if name == "li" => { + self.frameset_ok = false; + // Close any open li in list item scope + for i in (0..self.open_elements.len()).rev() { + let entry_name = self.open_elements[i].name.clone(); + if entry_name == "li" { + self.generate_implied_end_tags(Some("li")); + while let Some(e) = self.open_elements.pop() { + if e.name == "li" { + break; + } + } + break; + } + if is_special_element_ns(&entry_name, self.open_elements[i].ns) + && !matches!(entry_name.as_str(), "address" | "div" | "p") + { + break; + } + } + if self.element_in_button_scope("p") { + self.close_p_element(); + } + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + } + } + Token::StartTag { ref name, .. } if matches!(name.as_str(), "dd" | "dt") => { + self.frameset_ok = false; + for i in (0..self.open_elements.len()).rev() { + let entry_name = self.open_elements[i].name.clone(); + if matches!(entry_name.as_str(), "dd" | "dt") { + self.generate_implied_end_tags(Some(&entry_name)); + while let Some(e) = self.open_elements.pop() { + if e.name == entry_name { + break; + } + } + break; + } + if is_special_element_ns(&entry_name, self.open_elements[i].ns) + && !matches!(entry_name.as_str(), "address" | "div" | "p") + { + break; + } + } + if self.element_in_button_scope("p") { + self.close_p_element(); + } + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + } + } + Token::StartTag { ref name, .. } if name == "plaintext" => { + if self.element_in_button_scope("p") { + self.close_p_element(); + } + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.tokenizer.set_state(State::Plaintext); + } + } + Token::StartTag { ref name, .. } if name == "button" => { + if self.element_in_scope("button") { + self.generate_implied_end_tags(None); + while let Some(e) = self.open_elements.pop() { + if e.name == "button" { + break; + } + } + } + self.reconstruct_formatting(); + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.frameset_ok = false; + } + } + Token::EndTag { ref name } + if matches!( + name.as_str(), + "address" + | "article" + | "aside" + | "blockquote" + | "button" + | "center" + | "details" + | "dialog" + | "dir" + | "div" + | "dl" + | "fieldset" + | "figcaption" + | "figure" + | "footer" + | "header" + | "hgroup" + | "listing" + | "main" + | "menu" + | "nav" + | "ol" + | "pre" + | "search" + | "section" + | "summary" + | "ul" + ) => + { + if let Token::EndTag { name } = token { + if self.element_in_scope(&name) { + self.generate_implied_end_tags(None); + while let Some(e) = self.open_elements.pop() { + if e.name == name { + break; + } + } + } + } + } + Token::EndTag { ref name } if name == "form" => { + if !self.open_elements.iter().any(|e| e.name == "template") { + let node = self.form_pointer.take(); + if let Some(form_id) = node { + if self.element_in_scope("form") { + self.generate_implied_end_tags(None); + if let Some(pos) = + self.open_elements.iter().position(|e| e.node_id == form_id) + { + self.open_elements.remove(pos); + } + } + } + } else if self.element_in_scope("form") { + self.generate_implied_end_tags(None); + while let Some(e) = self.open_elements.pop() { + if e.name == "form" { + break; + } + } + } + } + Token::EndTag { ref name } if name == "p" => { + if !self.element_in_button_scope("p") { + self.insert_html_element("p", &[]); + } + self.close_p_element(); + } + Token::EndTag { ref name } if name == "li" => { + if self.element_in_list_item_scope("li") { + self.generate_implied_end_tags(Some("li")); + while let Some(e) = self.open_elements.pop() { + if e.name == "li" { + break; + } + } + } + } + Token::EndTag { ref name } if matches!(name.as_str(), "dd" | "dt") => { + if let Token::EndTag { name } = token { + if self.element_in_scope(&name) { + self.generate_implied_end_tags(Some(&name)); + while let Some(e) = self.open_elements.pop() { + if e.name == name { + break; + } + } + } + } + } + Token::EndTag { ref name } if is_heading(name) => { + if self.element_in_scope("h1") + || self.element_in_scope("h2") + || self.element_in_scope("h3") + || self.element_in_scope("h4") + || self.element_in_scope("h5") + || self.element_in_scope("h6") + { + self.generate_implied_end_tags(None); + while let Some(e) = self.open_elements.pop() { + if is_heading(&e.name) { + break; + } + } + } + } + Token::StartTag { ref name, .. } if name == "a" => { + // Check if there's already an 'a' between the end of the + // formatting list and the last marker (per spec §13.2.6.4.7). + let existing_a = { + let mut found = None; + for (i, entry) in self.active_formatting.iter().enumerate().rev() { + match entry { + FormatEntry::Marker => break, + FormatEntry::Element { name, .. } if name == "a" => { + found = Some(i); + break; + } + FormatEntry::Element { .. } => {} + } + } + found + }; + if existing_a.is_some() { + self.adoption_agency("a"); + // Remove from formatting list if still there (only + // between end and last marker, matching the search above). + let mut remove_pos = None; + for (i, entry) in self.active_formatting.iter().enumerate().rev() { + match entry { + FormatEntry::Marker => break, + FormatEntry::Element { name, .. } if name == "a" => { + remove_pos = Some(i); + break; + } + FormatEntry::Element { .. } => {} + } + } + if let Some(pos) = remove_pos { + let entry = self.active_formatting.remove(pos); + if let FormatEntry::Element { node_id, .. } = entry { + if let Some(stack_pos) = + self.open_elements.iter().position(|e| e.node_id == node_id) + { + self.open_elements.remove(stack_pos); + } + } + } + } + self.reconstruct_formatting(); + if let Token::StartTag { + name, attributes, .. + } = token + { + let node_id = self.insert_html_element(&name, &attributes); + self.push_formatting(node_id, &name, &attributes); + } + } + Token::StartTag { ref name, .. } + if matches!( + name.as_str(), + "b" | "big" + | "code" + | "em" + | "font" + | "i" + | "s" + | "small" + | "strike" + | "strong" + | "tt" + | "u" + ) => + { + self.reconstruct_formatting(); + if let Token::StartTag { + name, attributes, .. + } = token + { + let node_id = self.insert_html_element(&name, &attributes); + self.push_formatting(node_id, &name, &attributes); + } + } + Token::StartTag { ref name, .. } if name == "nobr" => { + self.reconstruct_formatting(); + if self.element_in_scope("nobr") { + self.adoption_agency("nobr"); + self.reconstruct_formatting(); + } + if let Token::StartTag { + name, attributes, .. + } = token + { + let node_id = self.insert_html_element(&name, &attributes); + self.push_formatting(node_id, &name, &attributes); + } + } + Token::EndTag { ref name } if is_formatting_element(name) => { + if let Token::EndTag { name } = token { + self.adoption_agency(&name); + } + } + Token::StartTag { ref name, .. } + if matches!(name.as_str(), "applet" | "marquee" | "object") => + { + self.reconstruct_formatting(); + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.push_formatting_marker(); + self.frameset_ok = false; + } + } + Token::EndTag { ref name } + if matches!(name.as_str(), "applet" | "marquee" | "object") => + { + if let Token::EndTag { name } = token { + if self.element_in_scope(&name) { + self.generate_implied_end_tags(None); + while let Some(e) = self.open_elements.pop() { + if e.name == name { + break; + } + } + self.clear_formatting_to_marker(); + } + } + } + Token::StartTag { ref name, .. } if name == "table" => { + if self.quirks_mode != QuirksMode::Quirks && self.element_in_button_scope("p") { + self.close_p_element(); + } + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.frameset_ok = false; + self.mode = InsertionMode::InTable; + } + } + Token::EndTag { ref name } if name == "br" => { + // Parse error — treat as start tag + self.reconstruct_formatting(); + self.insert_html_element("br", &[]); + self.open_elements.pop(); + self.frameset_ok = false; + } + Token::StartTag { ref name, .. } + if matches!( + name.as_str(), + "area" | "br" | "embed" | "img" | "keygen" | "wbr" + ) => + { + self.reconstruct_formatting(); + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.open_elements.pop(); // void + self.frameset_ok = false; + } + } + Token::StartTag { ref name, .. } if name == "input" => { + self.reconstruct_formatting(); + if let Token::StartTag { + name, attributes, .. + } = token + { + let is_hidden = attributes + .iter() + .any(|a| a.name == "type" && a.value.eq_ignore_ascii_case("hidden")); + self.insert_html_element(&name, &attributes); + self.open_elements.pop(); // void + if !is_hidden { + self.frameset_ok = false; + } + } + } + Token::StartTag { ref name, .. } + if matches!(name.as_str(), "param" | "source" | "track") => + { + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.open_elements.pop(); // void + } + } + Token::StartTag { ref name, .. } if name == "hr" => { + if self.element_in_button_scope("p") { + self.close_p_element(); + } + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.open_elements.pop(); // void + self.frameset_ok = false; + } + } + Token::StartTag { ref name, .. } if name == "image" => { + // Parse error — change to "img" + self.reconstruct_formatting(); + if let Token::StartTag { attributes, .. } = token { + self.insert_html_element("img", &attributes); + self.open_elements.pop(); + self.frameset_ok = false; + } + } + Token::StartTag { ref name, .. } if name == "textarea" => { + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.skip_next_lf = true; + self.tokenizer.set_state(State::RcData); + self.tokenizer.set_last_start_tag(&name); + self.original_mode = self.mode; + self.frameset_ok = false; + self.mode = InsertionMode::Text; + } + } + Token::StartTag { ref name, .. } if name == "xmp" => { + if self.element_in_button_scope("p") { + self.close_p_element(); + } + self.reconstruct_formatting(); + self.frameset_ok = false; + if let Token::StartTag { + name, attributes, .. + } = token + { + self.parse_raw_text(&name, &attributes); + } + } + Token::StartTag { ref name, .. } if name == "iframe" => { + self.frameset_ok = false; + if let Token::StartTag { + name, attributes, .. + } = token + { + self.parse_raw_text(&name, &attributes); + } + } + Token::StartTag { ref name, .. } if name == "noembed" => { + if let Token::StartTag { + name, attributes, .. + } = token + { + self.parse_raw_text(&name, &attributes); + } + } + Token::StartTag { ref name, .. } if name == "noscript" && self.scripting => { + if let Token::StartTag { + name, attributes, .. + } = token + { + self.parse_raw_text(&name, &attributes); + } + } + Token::StartTag { ref name, .. } if name == "select" => { + self.reconstruct_formatting(); + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.frameset_ok = false; + match self.mode { + InsertionMode::InTable + | InsertionMode::InCaption + | InsertionMode::InTableBody + | InsertionMode::InRow + | InsertionMode::InCell => { + self.mode = InsertionMode::InSelectInTable; + } + _ => { + self.mode = InsertionMode::InSelect; + } + } + } + } + Token::StartTag { ref name, .. } if matches!(name.as_str(), "optgroup" | "option") => { + if self.current_node_name() == "option" { + self.open_elements.pop(); + } + self.reconstruct_formatting(); + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + } + } + Token::StartTag { ref name, .. } if matches!(name.as_str(), "rb" | "rtc") => { + if self.element_in_scope("ruby") { + self.generate_implied_end_tags(None); + } + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + } + } + Token::StartTag { ref name, .. } if matches!(name.as_str(), "rp" | "rt") => { + if self.element_in_scope("ruby") { + self.generate_implied_end_tags(Some("rtc")); + } + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + } + } + Token::StartTag { ref name, .. } if name == "math" => { + self.reconstruct_formatting(); + if let Token::StartTag { + name: _, + attributes, + self_closing, + .. + } = token + { + // Adjust MathML attributes and foreign attributes per spec. + let adjusted: Vec<tokenizer::Attribute> = attributes + .iter() + .map(|a| tokenizer::Attribute { + name: adjust_mathml_attributes(&a.name).to_string(), + value: a.value.clone(), + }) + .collect(); + self.insert_foreign_element("math", &adjusted, Namespace::MathMl); + if self_closing { + self.open_elements.pop(); + } + } + } + Token::StartTag { ref name, .. } if name == "svg" => { + self.reconstruct_formatting(); + if let Token::StartTag { + name: _, + attributes, + self_closing, + .. + } = token + { + // Adjust SVG attributes and foreign attributes per spec. + let adjusted: Vec<tokenizer::Attribute> = attributes + .iter() + .map(|a| tokenizer::Attribute { + name: adjust_svg_attributes(&a.name).to_string(), + value: a.value.clone(), + }) + .collect(); + self.insert_foreign_element("svg", &adjusted, Namespace::Svg); + if self_closing { + self.open_elements.pop(); + } + } + } + Token::StartTag { ref name, .. } + if matches!( + name.as_str(), + "caption" + | "col" + | "colgroup" + | "frame" + | "head" + | "tbody" + | "td" + | "tfoot" + | "th" + | "thead" + | "tr" + ) => + { + // Parse error, ignore + } + Token::StartTag { + name, attributes, .. + } => { + // Any other start tag + self.reconstruct_formatting(); + self.insert_html_element(&name, &attributes); + } + Token::EndTag { name } => { + // Any other end tag + self.handle_any_other_end_tag(&name); + } + } + } + + fn handle_any_other_end_tag(&mut self, name: &str) { + for i in (0..self.open_elements.len()).rev() { + if self.open_elements[i].name == name && self.open_elements[i].ns == Namespace::Html { + self.generate_implied_end_tags(Some(name)); + while self.open_elements.len() > i { + self.open_elements.pop(); + } + return; + } + if is_special_element_ns(&self.open_elements[i].name, self.open_elements[i].ns) { + return; // Parse error, ignore + } + } + } + + #[allow(clippy::needless_pass_by_value)] + fn handle_text(&mut self, token: Token) { + match token { + Token::Character(c) => { + self.insert_character(c); + } + Token::Eof => { + self.open_elements.pop(); + self.mode = self.original_mode; + self.process_token(Token::Eof); + } + Token::EndTag { .. } => { + self.open_elements.pop(); + self.mode = self.original_mode; + } + _ => {} + } + } + + #[allow(clippy::too_many_lines)] + fn handle_in_table(&mut self, token: Token) { + match token { + Token::Character(_) + if matches!( + self.current_node_name(), + "table" | "tbody" | "tfoot" | "thead" | "tr" + ) => + { + self.pending_table_chars.clear(); + self.original_mode = self.mode; + self.mode = InsertionMode::InTableText; + self.process_token(token); + } + Token::Comment(data) => self.insert_comment(&data), + Token::Doctype { .. } => { /* ignore */ } + Token::StartTag { ref name, .. } if name == "caption" => { + self.clear_stack_back_to_table_context(); + self.push_formatting_marker(); + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.mode = InsertionMode::InCaption; + } + } + Token::StartTag { ref name, .. } if name == "colgroup" => { + self.clear_stack_back_to_table_context(); + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.mode = InsertionMode::InColumnGroup; + } + } + Token::StartTag { ref name, .. } if name == "col" => { + self.clear_stack_back_to_table_context(); + self.insert_html_element("colgroup", &[]); + self.mode = InsertionMode::InColumnGroup; + self.process_token(token); + } + Token::StartTag { ref name, .. } + if matches!(name.as_str(), "tbody" | "tfoot" | "thead") => + { + self.clear_stack_back_to_table_context(); + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.mode = InsertionMode::InTableBody; + } + } + Token::StartTag { ref name, .. } if matches!(name.as_str(), "td" | "th" | "tr") => { + self.clear_stack_back_to_table_context(); + self.insert_html_element("tbody", &[]); + self.mode = InsertionMode::InTableBody; + self.process_token(token); + } + Token::StartTag { ref name, .. } if name == "table" => { + if self.element_in_table_scope("table") { + while let Some(e) = self.open_elements.pop() { + if e.name == "table" { + break; + } + } + self.reset_insertion_mode(); + self.process_token(token); + } + } + Token::EndTag { ref name } if name == "table" => { + if self.element_in_table_scope("table") { + while let Some(e) = self.open_elements.pop() { + if e.name == "table" { + break; + } + } + self.reset_insertion_mode(); + } + } + Token::EndTag { ref name } + if matches!( + name.as_str(), + "body" + | "caption" + | "col" + | "colgroup" + | "html" + | "tbody" + | "td" + | "tfoot" + | "th" + | "thead" + | "tr" + ) => + { + // Parse error, ignore + } + Token::StartTag { ref name, .. } + if matches!(name.as_str(), "style" | "script" | "template") => + { + self.handle_in_head(token); + } + Token::EndTag { ref name } if name == "template" => { + self.handle_in_head(token); + } + Token::StartTag { ref name, .. } if name == "input" => { + if let Token::StartTag { ref attributes, .. } = token { + let is_hidden = attributes + .iter() + .any(|a| a.name == "type" && a.value.eq_ignore_ascii_case("hidden")); + if is_hidden { + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.open_elements.pop(); + } + } else { + self.foster_parenting = true; + self.handle_in_body(token); + self.foster_parenting = false; + } + } + } + Token::StartTag { ref name, .. } if name == "form" => { + if self.form_pointer.is_none() + && !self.open_elements.iter().any(|e| e.name == "template") + { + if let Token::StartTag { + name, attributes, .. + } = token + { + let node_id = self.insert_html_element(&name, &attributes); + self.form_pointer = Some(node_id); + self.open_elements.pop(); + } + } + } + Token::Eof => { + self.handle_in_body(token); + } + _ => { + // Foster parenting + self.foster_parenting = true; + self.handle_in_body(token); + self.foster_parenting = false; + } + } + } + + fn handle_in_table_text(&mut self, token: Token) { + match token { + Token::Character('\0') => { /* ignore */ } + Token::Character(c) => { + self.pending_table_chars.push(c); + } + _ => { + let chars: Vec<char> = std::mem::take(&mut self.pending_table_chars); + let has_non_ws = chars.iter().any(|c| !is_ascii_whitespace(*c)); + if has_non_ws { + // Foster parent each character + self.foster_parenting = true; + for c in chars { + self.reconstruct_formatting(); + self.insert_character(c); + if !is_ascii_whitespace(c) { + self.frameset_ok = false; + } + } + self.foster_parenting = false; + } else { + for c in chars { + self.insert_character(c); + } + } + self.mode = self.original_mode; + self.process_token(token); + } + } + } + + fn handle_in_caption(&mut self, token: Token) { + match token { + Token::EndTag { ref name } if name == "caption" => { + if self.element_in_table_scope("caption") { + self.generate_implied_end_tags(None); + while let Some(e) = self.open_elements.pop() { + if e.name == "caption" { + break; + } + } + self.clear_formatting_to_marker(); + self.mode = InsertionMode::InTable; + } + } + Token::StartTag { ref name, .. } + if matches!( + name.as_str(), + "caption" + | "col" + | "colgroup" + | "tbody" + | "td" + | "tfoot" + | "th" + | "thead" + | "tr" + ) => + { + if self.element_in_table_scope("caption") { + self.generate_implied_end_tags(None); + while let Some(e) = self.open_elements.pop() { + if e.name == "caption" { + break; + } + } + self.clear_formatting_to_marker(); + self.mode = InsertionMode::InTable; + self.process_token(token); + } + } + Token::EndTag { ref name } if name == "table" => { + if self.element_in_table_scope("caption") { + self.generate_implied_end_tags(None); + while let Some(e) = self.open_elements.pop() { + if e.name == "caption" { + break; + } + } + self.clear_formatting_to_marker(); + self.mode = InsertionMode::InTable; + self.process_token(token); + } + } + Token::EndTag { ref name } + if matches!( + name.as_str(), + "body" + | "col" + | "colgroup" + | "html" + | "tbody" + | "td" + | "tfoot" + | "th" + | "thead" + | "tr" + ) => + { + // ignore + } + _ => { + self.handle_in_body(token); + } + } + } + + fn handle_in_column_group(&mut self, token: Token) { + match token { + Token::Character(c) if is_ascii_whitespace(c) => { + self.insert_character(c); + } + Token::Comment(data) => self.insert_comment(&data), + Token::Doctype { .. } => { /* ignore */ } + Token::StartTag { ref name, .. } if name == "html" => { + self.handle_in_body(token); + } + Token::StartTag { ref name, .. } if name == "col" => { + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.open_elements.pop(); // void + } + } + Token::EndTag { ref name } if name == "colgroup" => { + if self.current_node_name() == "colgroup" { + self.open_elements.pop(); + self.mode = InsertionMode::InTable; + } + // else: parse error, ignore + } + Token::EndTag { ref name } if name == "col" => { + // parse error, ignore + } + Token::StartTag { ref name, .. } if name == "template" => { + self.handle_in_head(token); + } + Token::EndTag { ref name } if name == "template" => { + self.handle_in_head(token); + } + Token::Eof => { + self.handle_in_body(token); + } + _ => { + if self.current_node_name() == "colgroup" { + self.open_elements.pop(); + self.mode = InsertionMode::InTable; + self.process_token(token); + } + } + } + } + + fn handle_in_table_body(&mut self, token: Token) { + match token { + Token::StartTag { ref name, .. } if name == "tr" => { + self.clear_stack_back_to_table_body_context(); + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.mode = InsertionMode::InRow; + } + } + Token::StartTag { ref name, .. } if matches!(name.as_str(), "th" | "td") => { + self.clear_stack_back_to_table_body_context(); + self.insert_html_element("tr", &[]); + self.mode = InsertionMode::InRow; + self.process_token(token); + } + Token::EndTag { ref name } if matches!(name.as_str(), "tbody" | "tfoot" | "thead") => { + if let Token::EndTag { name } = token { + if self.element_in_table_scope(&name) { + self.clear_stack_back_to_table_body_context(); + self.open_elements.pop(); + self.mode = InsertionMode::InTable; + } + } + } + Token::StartTag { ref name, .. } + if matches!( + name.as_str(), + "caption" | "col" | "colgroup" | "tbody" | "tfoot" | "thead" + ) => + { + if self.element_in_table_scope("tbody") + || self.element_in_table_scope("thead") + || self.element_in_table_scope("tfoot") + { + self.clear_stack_back_to_table_body_context(); + self.open_elements.pop(); + self.mode = InsertionMode::InTable; + self.process_token(token); + } + } + Token::EndTag { ref name } if name == "table" => { + if self.element_in_table_scope("tbody") + || self.element_in_table_scope("thead") + || self.element_in_table_scope("tfoot") + { + self.clear_stack_back_to_table_body_context(); + self.open_elements.pop(); + self.mode = InsertionMode::InTable; + self.process_token(token); + } + } + Token::EndTag { ref name } + if matches!( + name.as_str(), + "body" | "caption" | "col" | "colgroup" | "html" | "td" | "th" | "tr" + ) => + { + // ignore + } + _ => { + self.handle_in_table(token); + } + } + } + + fn handle_in_row(&mut self, token: Token) { + match token { + Token::StartTag { ref name, .. } if matches!(name.as_str(), "th" | "td") => { + self.clear_stack_back_to_table_row_context(); + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.mode = InsertionMode::InCell; + self.push_formatting_marker(); + } + } + Token::EndTag { ref name } if name == "tr" => { + if self.element_in_table_scope("tr") { + self.clear_stack_back_to_table_row_context(); + self.open_elements.pop(); + self.mode = InsertionMode::InTableBody; + } + } + Token::StartTag { ref name, .. } + if matches!( + name.as_str(), + "caption" | "col" | "colgroup" | "tbody" | "tfoot" | "thead" | "tr" + ) => + { + if self.element_in_table_scope("tr") { + self.clear_stack_back_to_table_row_context(); + self.open_elements.pop(); + self.mode = InsertionMode::InTableBody; + self.process_token(token); + } + } + Token::EndTag { ref name } if name == "table" => { + if self.element_in_table_scope("tr") { + self.clear_stack_back_to_table_row_context(); + self.open_elements.pop(); + self.mode = InsertionMode::InTableBody; + self.process_token(token); + } + } + Token::EndTag { ref name } if matches!(name.as_str(), "tbody" | "tfoot" | "thead") => { + if self.element_in_table_scope(name) && self.element_in_table_scope("tr") { + self.clear_stack_back_to_table_row_context(); + self.open_elements.pop(); + self.mode = InsertionMode::InTableBody; + self.process_token(token); + } + } + Token::EndTag { ref name } + if matches!( + name.as_str(), + "body" | "caption" | "col" | "colgroup" | "html" | "td" | "th" + ) => + { + // ignore + } + _ => { + self.handle_in_table(token); + } + } + } + + fn handle_in_cell(&mut self, token: Token) { + match token { + Token::EndTag { ref name } if matches!(name.as_str(), "td" | "th") => { + if let Token::EndTag { name } = token { + if self.element_in_table_scope(&name) { + self.generate_implied_end_tags(None); + while let Some(e) = self.open_elements.pop() { + if e.name == name && e.ns == Namespace::Html { + break; + } + } + self.clear_formatting_to_marker(); + self.mode = InsertionMode::InRow; + } + } + } + Token::StartTag { ref name, .. } + if matches!( + name.as_str(), + "caption" + | "col" + | "colgroup" + | "tbody" + | "td" + | "tfoot" + | "th" + | "thead" + | "tr" + ) => + { + if self.element_in_table_scope("td") || self.element_in_table_scope("th") { + self.close_cell(); + self.process_token(token); + } + } + Token::EndTag { ref name } + if matches!( + name.as_str(), + "body" | "caption" | "col" | "colgroup" | "html" + ) => + { + // ignore + } + Token::EndTag { ref name } + if matches!(name.as_str(), "table" | "tbody" | "tfoot" | "thead" | "tr") => + { + if let Token::EndTag { ref name } = token { + if self.element_in_table_scope(name) { + self.close_cell(); + self.process_token(token); + } + } + } + _ => { + self.handle_in_body(token); + } + } + } + + fn close_cell(&mut self) { + self.generate_implied_end_tags(None); + while let Some(e) = self.open_elements.pop() { + if matches!(e.name.as_str(), "td" | "th") { + break; + } + } + self.clear_formatting_to_marker(); + self.mode = InsertionMode::InRow; + } + + #[allow(clippy::too_many_lines, clippy::match_same_arms)] + fn handle_in_select(&mut self, token: Token) { + match token { + Token::Character('\0') => { /* ignore */ } + Token::Character(c) => { + self.reconstruct_formatting(); + self.insert_character(c); + } + Token::Comment(data) => self.insert_comment(&data), + Token::Doctype { .. } => { /* ignore */ } + Token::StartTag { ref name, .. } if name == "html" => { + self.handle_in_body(token); + } + Token::StartTag { ref name, .. } if name == "option" => { + if self.current_node_name() == "option" { + self.open_elements.pop(); + } + self.reconstruct_formatting(); + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + } + } + Token::StartTag { ref name, .. } if name == "optgroup" => { + if self.current_node_name() == "option" { + self.open_elements.pop(); + } + if self.current_node_name() == "optgroup" { + self.open_elements.pop(); + } + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + } + } + Token::StartTag { ref name, .. } if name == "hr" => { + if self.current_node_name() == "option" { + self.open_elements.pop(); + } + if self.current_node_name() == "optgroup" { + self.open_elements.pop(); + } + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.open_elements.pop(); // void + } + } + Token::EndTag { ref name } if name == "optgroup" => { + if self.current_node_name() == "option" + && self.open_elements.len() >= 2 + && self.open_elements[self.open_elements.len() - 2].name == "optgroup" + { + self.open_elements.pop(); + } + if self.current_node_name() == "optgroup" { + self.open_elements.pop(); + } + } + Token::EndTag { ref name } if name == "option" => { + if self.current_node_name() == "option" { + self.open_elements.pop(); + } + } + Token::EndTag { ref name } if name == "select" => { + if self.element_in_select_scope("select") { + while let Some(e) = self.open_elements.pop() { + if e.name == "select" { + break; + } + } + self.reset_insertion_mode(); + } + } + Token::StartTag { ref name, .. } if name == "select" => { + // Parse error — act as end tag + if self.element_in_select_scope("select") { + while let Some(e) = self.open_elements.pop() { + if e.name == "select" { + break; + } + } + self.reset_insertion_mode(); + } + } + Token::StartTag { ref name, .. } if matches!(name.as_str(), "input" | "textarea") => { + // Close select and reprocess + if self.element_in_select_scope("select") { + while let Some(e) = self.open_elements.pop() { + if e.name == "select" { + break; + } + } + self.reset_insertion_mode(); + self.process_token(token); + } else if self + .fragment_context + .as_ref() + .is_some_and(|(n, ns)| n == "select" && *ns == Namespace::Html) + { + // Fragment case: context is select but it's not on the stack. + // Switch to InBody and reprocess. + self.mode = InsertionMode::InBody; + self.process_token(token); + } + } + Token::StartTag { ref name, .. } if matches!(name.as_str(), "script" | "template") => { + self.handle_in_head(token); + } + Token::EndTag { ref name } if name == "template" => { + self.handle_in_head(token); + } + // New select content model: allow certain elements inside <select>. + Token::StartTag { ref name, .. } + if matches!( + name.as_str(), + "div" | "button" | "datalist" | "selectedcontent" + ) => + { + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + } + } + Token::EndTag { ref name } + if matches!( + name.as_str(), + "div" | "button" | "datalist" | "selectedcontent" + ) => + { + if self.element_in_scope(name) { + self.generate_implied_end_tags(Some(name)); + while let Some(e) = self.open_elements.pop() { + if e.name == *name { + break; + } + } + } + } + Token::StartTag { ref name, .. } if name == "svg" => { + self.reconstruct_formatting(); + if let Token::StartTag { + name: _, + attributes, + self_closing, + .. + } = token + { + let adjusted: Vec<tokenizer::Attribute> = attributes + .iter() + .map(|a| tokenizer::Attribute { + name: adjust_svg_attributes(&a.name).to_string(), + value: a.value.clone(), + }) + .collect(); + self.insert_foreign_element("svg", &adjusted, Namespace::Svg); + if self_closing { + self.open_elements.pop(); + } + } + } + Token::StartTag { ref name, .. } if name == "math" => { + self.reconstruct_formatting(); + if let Token::StartTag { + name: _, + attributes, + self_closing, + .. + } = token + { + let adjusted: Vec<tokenizer::Attribute> = attributes + .iter() + .map(|a| tokenizer::Attribute { + name: adjust_mathml_attributes(&a.name).to_string(), + value: a.value.clone(), + }) + .collect(); + self.insert_foreign_element("math", &adjusted, Namespace::MathMl); + if self_closing { + self.open_elements.pop(); + } + } + } + // New select content model: allow most other start tags + // by processing them via InBody rules. + Token::StartTag { .. } => { + self.handle_in_body(token); + } + // End tags for elements opened inside select via InBody. + Token::EndTag { ref name } + if self + .open_elements + .iter() + .rev() + .take_while(|e| e.name != "select") + .any(|e| e.name == *name && e.ns == Namespace::Html) => + { + self.handle_in_body(token); + } + Token::Eof => { + self.handle_in_body(token); + } + Token::EndTag { .. } => { /* ignore */ } + } + } + + fn handle_in_select_in_table(&mut self, token: Token) { + match token { + Token::StartTag { ref name, .. } + if matches!( + name.as_str(), + "caption" | "table" | "tbody" | "tfoot" | "thead" | "tr" | "td" | "th" + ) => + { + while let Some(e) = self.open_elements.pop() { + if e.name == "select" { + break; + } + } + self.reset_insertion_mode(); + self.process_token(token); + } + Token::EndTag { ref name } + if matches!( + name.as_str(), + "caption" | "table" | "tbody" | "tfoot" | "thead" | "tr" | "td" | "th" + ) => + { + if let Token::EndTag { ref name } = token { + if self.element_in_table_scope(name) { + while let Some(e) = self.open_elements.pop() { + if e.name == "select" { + break; + } + } + self.reset_insertion_mode(); + self.process_token(token); + } + } + } + _ => { + self.handle_in_select(token); + } + } + } + + #[allow(clippy::match_same_arms)] + fn handle_in_template(&mut self, token: Token) { + match token { + Token::Character(_) | Token::Comment(_) | Token::Doctype { .. } => { + self.handle_in_body(token); + } + Token::StartTag { ref name, .. } + if matches!( + name.as_str(), + "base" + | "basefont" + | "bgsound" + | "link" + | "meta" + | "noframes" + | "script" + | "style" + | "template" + | "title" + ) => + { + self.handle_in_head(token); + } + Token::EndTag { ref name } if name == "template" => { + self.handle_in_head(token); + } + Token::StartTag { ref name, .. } + if matches!( + name.as_str(), + "caption" | "colgroup" | "tbody" | "tfoot" | "thead" + ) => + { + self.template_modes.pop(); + self.template_modes.push(InsertionMode::InTable); + self.mode = InsertionMode::InTable; + self.process_token(token); + } + Token::StartTag { ref name, .. } if name == "col" => { + self.template_modes.pop(); + self.template_modes.push(InsertionMode::InColumnGroup); + self.mode = InsertionMode::InColumnGroup; + self.process_token(token); + } + Token::StartTag { ref name, .. } if name == "tr" => { + self.template_modes.pop(); + self.template_modes.push(InsertionMode::InTableBody); + self.mode = InsertionMode::InTableBody; + self.process_token(token); + } + Token::StartTag { ref name, .. } if matches!(name.as_str(), "td" | "th") => { + self.template_modes.pop(); + self.template_modes.push(InsertionMode::InRow); + self.mode = InsertionMode::InRow; + self.process_token(token); + } + Token::Eof => { + if self + .open_elements + .iter() + .any(|e| e.name == "template" && e.ns == Namespace::Html) + { + self.generate_all_implied_end_tags(); + while let Some(e) = self.open_elements.pop() { + if e.name == "template" && e.ns == Namespace::Html { + break; + } + } + self.clear_formatting_to_marker(); + self.template_modes.pop(); + self.reset_insertion_mode(); + self.process_token(Token::Eof); + } + // else: stop parsing + } + Token::StartTag { .. } => { + self.template_modes.pop(); + self.template_modes.push(InsertionMode::InBody); + self.mode = InsertionMode::InBody; + self.process_token(token); + } + Token::EndTag { .. } => { + // ignore + } + } + } + + #[allow(clippy::match_same_arms)] + fn handle_after_body(&mut self, token: Token) { + match token { + Token::Character(c) if is_ascii_whitespace(c) => { + self.handle_in_body(token); + } + Token::Comment(data) => { + // Append to the html element (first in stack) + if let Some(html_entry) = self.open_elements.first() { + let html_id = html_entry.node_id; + let comment_id = self.doc.create_node(NodeKind::Comment { content: data }); + self.doc.append_child(html_id, comment_id); + } + } + Token::Doctype { .. } => { /* ignore */ } + Token::StartTag { ref name, .. } if name == "html" => { + self.handle_in_body(token); + } + Token::EndTag { ref name } if name == "html" => { + if self.fragment_context.is_some() { + // Fragment case: ignore the token (parse error). + } else { + self.mode = InsertionMode::AfterAfterBody; + } + } + Token::Eof => { + // Stop parsing + } + _ => { + self.mode = InsertionMode::InBody; + self.process_token(token); + } + } + } + + #[allow(clippy::match_same_arms)] + fn handle_in_frameset(&mut self, token: Token) { + match token { + Token::Character(c) if is_ascii_whitespace(c) => { + self.insert_character(c); + } + Token::Comment(data) => self.insert_comment(&data), + Token::Doctype { .. } => { /* ignore */ } + Token::StartTag { ref name, .. } if name == "html" => { + self.handle_in_body(token); + } + Token::StartTag { ref name, .. } if name == "frameset" => { + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + } + } + Token::EndTag { ref name } + if name == "frameset" && self.current_node_name() != "html" => + { + self.open_elements.pop(); + if self.current_node_name() != "frameset" { + self.mode = InsertionMode::AfterFrameset; + } + } + Token::StartTag { ref name, .. } if name == "frame" => { + if let Token::StartTag { + name, attributes, .. + } = token + { + self.insert_html_element(&name, &attributes); + self.open_elements.pop(); // void + } + } + Token::StartTag { ref name, .. } if name == "noframes" => { + self.handle_in_head(token); + } + Token::Eof => { + // Stop parsing + } + _ => { /* ignore */ } + } + } + + #[allow(clippy::match_same_arms)] + fn handle_after_frameset(&mut self, token: Token) { + match token { + Token::Character(c) if is_ascii_whitespace(c) => { + self.insert_character(c); + } + Token::Comment(data) => self.insert_comment(&data), + Token::Doctype { .. } => { /* ignore */ } + Token::StartTag { ref name, .. } if name == "html" => { + self.handle_in_body(token); + } + Token::EndTag { ref name } if name == "html" => { + self.mode = InsertionMode::AfterAfterFrameset; + } + Token::StartTag { ref name, .. } if name == "noframes" => { + self.handle_in_head(token); + } + Token::Eof => { + // Stop parsing + } + _ => { /* ignore */ } + } + } + + #[allow(clippy::match_same_arms)] + fn handle_after_after_body(&mut self, token: Token) { + match token { + Token::Comment(data) => { + self.insert_comment_at_document(&data); + } + Token::Doctype { .. } | Token::Character(' ' | '\t' | '\n' | '\x0C' | '\r') => { + self.handle_in_body(token); + } + Token::StartTag { ref name, .. } if name == "html" => { + self.handle_in_body(token); + } + Token::Eof => { + // Stop parsing + } + _ => { + self.mode = InsertionMode::InBody; + self.process_token(token); + } + } + } + + #[allow(clippy::match_same_arms)] + fn handle_after_after_frameset(&mut self, token: Token) { + match token { + Token::Comment(data) => { + self.insert_comment_at_document(&data); + } + Token::Doctype { .. } | Token::Character(' ' | '\t' | '\n' | '\x0C' | '\r') => { + self.handle_in_body(token); + } + Token::StartTag { ref name, .. } if name == "html" => { + self.handle_in_body(token); + } + Token::StartTag { ref name, .. } if name == "noframes" => { + self.handle_in_head(token); + } + Token::Eof => { + // Stop parsing + } + _ => { /* ignore */ } + } + } + + // ----------------------------------------------------------------------- + // Stack clearing helpers + // ----------------------------------------------------------------------- + + fn clear_stack_back_to_table_context(&mut self) { + while !self.open_elements.is_empty() { + if matches!(self.current_node_name(), "table" | "template" | "html") { + break; + } + self.open_elements.pop(); + } + } + + fn clear_stack_back_to_table_body_context(&mut self) { + while !self.open_elements.is_empty() { + if matches!( + self.current_node_name(), + "tbody" | "tfoot" | "thead" | "template" | "html" + ) { + break; + } + self.open_elements.pop(); + } + } + + fn clear_stack_back_to_table_row_context(&mut self) { + while !self.open_elements.is_empty() { + if matches!(self.current_node_name(), "tr" | "template" | "html") { + break; + } + self.open_elements.pop(); + } + } + + fn reset_insertion_mode(&mut self) { + for i in (0..self.open_elements.len()).rev() { + let last = i == 0; + // Per WHATWG §13.2.4.1: when last is true and this is fragment + // parsing, use the context element instead of the stack element. + let name = if last { + if let Some((ref ctx_name, _)) = self.fragment_context { + ctx_name.clone() + } else { + self.open_elements[i].name.clone() + } + } else { + self.open_elements[i].name.clone() + }; + match name.as_str() { + "select" => { + if !last { + // Walk up to find if we're in a table + for j in (0..i).rev() { + match self.open_elements[j].name.as_str() { + "template" => break, + "table" => { + self.mode = InsertionMode::InSelectInTable; + return; + } + _ => {} + } + } + } + self.mode = InsertionMode::InSelect; + return; + } + "td" | "th" if !last => { + self.mode = InsertionMode::InCell; + return; + } + "tr" => { + self.mode = InsertionMode::InRow; + return; + } + "tbody" | "thead" | "tfoot" => { + self.mode = InsertionMode::InTableBody; + return; + } + "caption" => { + self.mode = InsertionMode::InCaption; + return; + } + "colgroup" => { + self.mode = InsertionMode::InColumnGroup; + return; + } + "table" => { + self.mode = InsertionMode::InTable; + return; + } + "template" => { + self.mode = self + .template_modes + .last() + .copied() + .unwrap_or(InsertionMode::InBody); + return; + } + "head" if !last => { + self.mode = InsertionMode::InHead; + return; + } + "body" => { + self.mode = InsertionMode::InBody; + return; + } + "frameset" => { + self.mode = InsertionMode::InFrameset; + return; + } + "html" => { + if self.head_pointer.is_none() { + self.mode = InsertionMode::BeforeHead; + } else { + self.mode = InsertionMode::AfterHead; + } + return; + } + _ => {} + } + if last { + self.mode = InsertionMode::InBody; + return; + } + } + self.mode = InsertionMode::InBody; + } +} + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +fn is_ascii_whitespace(c: char) -> bool { + matches!(c, ' ' | '\t' | '\n' | '\x0C' | '\r') +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + /// Helper: parse HTML5, return the Document. + fn parse(input: &str) -> Document { + parse_html5(input).unwrap() + } + + /// Walk children and collect their node names (or "#text" / "#comment"). + fn child_names(doc: &Document, id: NodeId) -> Vec<String> { + doc.children(id) + .map(|c| match &doc.node(c).kind { + NodeKind::Element { name, .. } => name.clone(), + NodeKind::Text { .. } => "#text".to_string(), + NodeKind::Comment { .. } => "#comment".to_string(), + NodeKind::DocumentType { .. } => "#doctype".to_string(), + NodeKind::Document => "#document".to_string(), + _ => "#other".to_string(), + }) + .collect() + } + + #[test] + fn test_simple_text() { + let doc = parse("hello"); + let html = doc.root_element().unwrap(); + assert_eq!(doc.node_name(html), Some("html")); + let children = child_names(&doc, html); + assert_eq!(children, vec!["head", "body"]); + let body = doc.children(html).nth(1).unwrap(); + assert_eq!(doc.text_content(body), "hello"); + } + + #[test] + fn test_basic_element() { + let doc = parse("<p>hi</p>"); + let html = doc.root_element().unwrap(); + assert_eq!(doc.node_name(html), Some("html")); + let body = doc.children(html).nth(1).unwrap(); + assert_eq!(doc.node_name(body), Some("body")); + let p = doc.first_child(body).unwrap(); + assert_eq!(doc.node_name(p), Some("p")); + assert_eq!(doc.text_content(p), "hi"); + } + + #[test] + fn test_implied_tags() { + let doc = parse("test"); + let html = doc.root_element().unwrap(); + assert_eq!(doc.node_name(html), Some("html")); + let children = child_names(&doc, html); + assert_eq!(children, vec!["head", "body"]); + } + + #[test] + fn test_nested_elements() { + let doc = parse("<div><p>text</p></div>"); + let html = doc.root_element().unwrap(); + let body = doc.children(html).nth(1).unwrap(); + let div = doc.first_child(body).unwrap(); + assert_eq!(doc.node_name(div), Some("div")); + let p = doc.first_child(div).unwrap(); + assert_eq!(doc.node_name(p), Some("p")); + assert_eq!(doc.text_content(p), "text"); + } + + #[test] + fn test_auto_closing_p() { + let doc = parse("<p>one<p>two"); + let html = doc.root_element().unwrap(); + let body = doc.children(html).nth(1).unwrap(); + let children = child_names(&doc, body); + assert_eq!(children, vec!["p", "p"]); + let p1 = doc.first_child(body).unwrap(); + let p2 = doc.next_sibling(p1).unwrap(); + assert_eq!(doc.text_content(p1), "one"); + assert_eq!(doc.text_content(p2), "two"); + } + + #[test] + fn test_formatting_elements() { + let doc = parse("<b>bold</b>normal"); + let html = doc.root_element().unwrap(); + let body = doc.children(html).nth(1).unwrap(); + let b = doc.first_child(body).unwrap(); + assert_eq!(doc.node_name(b), Some("b")); + assert_eq!(doc.text_content(b), "bold"); + let text = doc.next_sibling(b).unwrap(); + assert_eq!(doc.node_text(text), Some("normal")); + } + + #[test] + fn test_adoption_agency() { + // Classic misnesting: <b><i>bi</b>i</i> + // Expected: <b><i>bi</i></b><i>i</i> + let doc = parse("<b><i>bi</b>i</i>"); + let html = doc.root_element().unwrap(); + let body = doc.children(html).nth(1).unwrap(); + let body_children = child_names(&doc, body); + // Should have: b, i + assert_eq!(body_children.len(), 2); + let b_elem = doc.first_child(body).unwrap(); + assert_eq!(doc.node_name(b_elem), Some("b")); + let i_in_b = doc.first_child(b_elem).unwrap(); + assert_eq!(doc.node_name(i_in_b), Some("i")); + assert_eq!(doc.text_content(i_in_b), "bi"); + let i_after_b = doc.next_sibling(b_elem).unwrap(); + assert_eq!(doc.node_name(i_after_b), Some("i")); + assert_eq!(doc.text_content(i_after_b), "i"); + } + + #[test] + fn test_void_elements() { + let doc = parse("<br><img><hr>"); + let html = doc.root_element().unwrap(); + let body = doc.children(html).nth(1).unwrap(); + let children = child_names(&doc, body); + assert_eq!(children, vec!["br", "img", "hr"]); + // Void elements should have no children + let br = doc.first_child(body).unwrap(); + assert!(doc.first_child(br).is_none()); + } + + #[test] + fn test_table_structure() { + let doc = parse("<table><tr><td>cell</td></tr></table>"); + let html = doc.root_element().unwrap(); + let body = doc.children(html).nth(1).unwrap(); + let table = doc.first_child(body).unwrap(); + assert_eq!(doc.node_name(table), Some("table")); + let tbody = doc.first_child(table).unwrap(); + assert_eq!(doc.node_name(tbody), Some("tbody")); + let tr = doc.first_child(tbody).unwrap(); + assert_eq!(doc.node_name(tr), Some("tr")); + let td = doc.first_child(tr).unwrap(); + assert_eq!(doc.node_name(td), Some("td")); + assert_eq!(doc.text_content(td), "cell"); + } + + #[test] + fn test_doctype() { + let doc = parse("<!DOCTYPE html><html><body>hi</body></html>"); + let root = doc.root(); + // First child should be the doctype + let first = doc.first_child(root).unwrap(); + assert!(matches!( + doc.node(first).kind, + NodeKind::DocumentType { .. } + )); + let html = doc.root_element().unwrap(); + assert_eq!(doc.node_name(html), Some("html")); + let body = doc.children(html).nth(1).unwrap(); + assert_eq!(doc.text_content(body), "hi"); + } + + #[test] + fn test_comment() { + let doc = parse("<!-- comment --><p>text</p>"); + let html = doc.root_element().unwrap(); + let body = doc.children(html).nth(1).unwrap(); + let p = doc.first_child(body).unwrap(); + assert_eq!(doc.node_name(p), Some("p")); + assert_eq!(doc.text_content(p), "text"); + } + + #[test] + fn test_self_closing_svg() { + let doc = parse("<svg><circle/></svg>"); + let html = doc.root_element().unwrap(); + let body = doc.children(html).nth(1).unwrap(); + let svg = doc.first_child(body).unwrap(); + assert_eq!(doc.node_name(svg), Some("svg")); + } + + #[test] + fn test_template() { + let doc = parse("<template><p>content</p></template>"); + let html = doc.root_element().unwrap(); + let head = doc.first_child(html).unwrap(); + assert_eq!(doc.node_name(head), Some("head")); + let template = doc.first_child(head).unwrap(); + assert_eq!(doc.node_name(template), Some("template")); + } + + #[test] + fn test_select() { + let doc = parse("<select><option>a</option><option>b</option></select>"); + let html = doc.root_element().unwrap(); + let body = doc.children(html).nth(1).unwrap(); + let select = doc.first_child(body).unwrap(); + assert_eq!(doc.node_name(select), Some("select")); + let children = child_names(&doc, select); + assert_eq!(children, vec!["option", "option"]); + } +} diff --git a/browser/vendor/xmloxide/src/lib.rs b/browser/vendor/xmloxide/src/lib.rs new file mode 100644 index 000000000..0b0464010 --- /dev/null +++ b/browser/vendor/xmloxide/src/lib.rs @@ -0,0 +1,61 @@ +//! # xmloxide +//! +//! A pure Rust reimplementation of libxml2 — the de facto standard XML/HTML +//! parsing library. Memory-safe, high-performance, and conformant with the +//! W3C XML 1.0 (Fifth Edition) specification. +//! +//! ## Modules +//! +//! - [`tree`] — DOM tree representation with arena-allocated nodes ([`Document`], [`NodeId`]) +//! - [`parser`] — XML 1.0 parser with error recovery and push/incremental parsing +//! - [`html`] — Error-tolerant HTML 4.01 parser +//! - [`html5`] — WHATWG HTML5 parser (tokenizer + tree construction) +//! - [`html5::sax`] — Streaming SAX-like API for HTML5 (no DOM tree built) +//! - [`css`] — CSS selector engine for querying document trees +//! - [`sax`] — SAX2 event-driven streaming parser +//! - [`reader`] — `XmlReader` pull-based parsing API +//! - [`xpath`] — `XPath` 1.0+ expression evaluation (includes key `XPath` 2.0 functions) +//! - [`validation`] — DTD, `RelaxNG`, XML Schema (XSD), and ISO Schematron validation +//! - [`serial`] — XML/HTML serialization and Canonical XML (C14N) +//! - [`encoding`] — Character encoding detection and conversion +//! - [`xinclude`] — `XInclude` 1.0 document inclusion +//! - [`catalog`] — OASIS XML Catalogs for URI resolution +//! - [`error`] — Error types and diagnostics +//! - [`serde_xml`] — Serde XML (de)serialization (requires `serde` feature) +//! - [`async_xml`] — Async parsing via `tokio::io::AsyncRead` (requires `async` feature) +//! +//! ## Quick Start +//! +//! ``` +//! use xmloxide::Document; +//! +//! let doc = Document::parse_str("<root><child>Hello</child></root>").unwrap(); +//! let root = doc.root_element().unwrap(); +//! assert_eq!(doc.node_name(root), Some("root")); +//! ``` + +#[cfg(feature = "async")] +pub mod async_xml; +pub mod catalog; +pub mod css; +pub mod encoding; +pub mod error; +#[cfg(feature = "ffi")] +pub mod ffi; +pub mod html; +pub mod html5; +pub mod parser; +pub mod reader; +pub mod sax; +#[cfg(feature = "serde")] +pub mod serde_xml; +pub mod serial; +pub mod tree; +#[allow(dead_code)] +pub(crate) mod util; +pub mod validation; +pub mod xinclude; +pub mod xpath; + +// Re-export primary types at the crate root for convenience. +pub use tree::{Attribute, Document, NodeId}; diff --git a/browser/vendor/xmloxide/src/parser/input.rs b/browser/vendor/xmloxide/src/parser/input.rs new file mode 100644 index 000000000..28e31bb8e --- /dev/null +++ b/browser/vendor/xmloxide/src/parser/input.rs @@ -0,0 +1,3497 @@ +//! Shared low-level input handling for XML and HTML parsers. +//! +//! [`ParserInput`] encapsulates the raw byte stream, position tracking +//! (line, column, byte offset), and common parsing primitives such as +//! peeking, advancing, name parsing, and entity reference resolution. +//! +//! # Security +//! +//! `ParserInput` tracks nesting depth and entity expansion count to guard +//! against denial-of-service attacks: +//! +//! - **Depth limit**: prevents stack overflow from deeply nested elements. +//! - **Entity expansion limit**: defense-in-depth counter for entity +//! references. Currently only the five built-in XML entities are +//! supported (amp, lt, gt, apos, quot), so recursive expansion is +//! impossible, but the limit protects against future DTD entity support +//! and documents with an unreasonable number of references. +//! - **Name length limit**: prevents memory exhaustion from huge names. +//! +//! No external entity loading is performed (immune to XXE). + +use std::collections::HashMap; + +use crate::error::{ErrorSeverity, ParseDiagnostic, ParseError, SourceLocation}; +use crate::parser::{EntityResolver, ExternalEntityRequest}; + +// ------------------------------------------------------------------------- +// Security defaults +// ------------------------------------------------------------------------- + +/// Default maximum element nesting depth. +pub(crate) const DEFAULT_MAX_DEPTH: u32 = 256; + +/// Default maximum number of attributes on a single element. +pub(crate) const DEFAULT_MAX_ATTRIBUTES: u32 = 256; + +/// Default maximum length (in bytes) of an attribute value. +pub(crate) const DEFAULT_MAX_ATTRIBUTE_LENGTH: usize = 10 * 1024 * 1024; // 10 MB + +/// Default maximum length (in bytes) of a text node. +pub(crate) const DEFAULT_MAX_TEXT_LENGTH: usize = 10 * 1024 * 1024; // 10 MB + +/// Default maximum length (in bytes) of an element or attribute name. +pub(crate) const DEFAULT_MAX_NAME_LENGTH: usize = 50_000; + +/// Default maximum number of entity expansions per document. +pub(crate) const DEFAULT_MAX_ENTITY_EXPANSIONS: u32 = 10_000; + +// ------------------------------------------------------------------------- +// XML Name character classes (XML 1.0 §2.3) +// ------------------------------------------------------------------------- + +/// Returns `true` if `c` is a valid `Char` per XML 1.0 §2.2 `[2]`. +/// +/// The XML 1.0 (Fifth Edition) `Char` production allows: +/// `#x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]` +pub(crate) fn is_xml_char(c: char) -> bool { + matches!(c as u32, + 0x09 | 0x0A | 0x0D | 0x20..=0xD7FF | 0xE000..=0xFFFD | 0x0001_0000..=0x0010_FFFF + ) +} + +/// Returns `true` if `c` is a valid `NameStartChar` per XML 1.0 §2.3 `[4]`. +pub(crate) fn is_name_start_char(c: char) -> bool { + matches!(c, + ':' | 'A'..='Z' | '_' | 'a'..='z' | + '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{2FF}' | + '\u{370}'..='\u{37D}' | '\u{37F}'..='\u{1FFF}' | + '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}' | + '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}' | + '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' | + '\u{10000}'..='\u{EFFFF}' + ) +} + +/// Returns `true` if `c` is a valid `NameChar` per XML 1.0 §2.3 [4a]. +pub(crate) fn is_name_char(c: char) -> bool { + is_name_start_char(c) + || matches!(c, + '-' | '.' | '0'..='9' | '\u{B7}' | + '\u{300}'..='\u{36F}' | '\u{203F}'..='\u{2040}' + ) +} + +/// Returns `true` if `b` is a valid ASCII `NameStartChar`. +/// +/// Covers the ASCII subset of XML 1.0 §2.3 `[4]`: `[A-Za-z_:]`. +fn is_ascii_name_start(b: u8) -> bool { + b.is_ascii_alphabetic() || b == b'_' || b == b':' +} + +/// Returns `true` if `b` is a valid ASCII `NameChar`. +/// +/// Covers the ASCII subset of XML 1.0 §2.3 `[4a]`: `[A-Za-z0-9_:.-]`. +fn is_ascii_name_char(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || b == b':' || b == b'-' || b == b'.' +} + +/// Matches one of the five XML builtin entity references at the byte level. +/// +/// Given bytes starting after `&`, returns `(replacement_char, bytes_to_skip)` +/// if the bytes match a builtin entity (`amp;`, `lt;`, `gt;`, `apos;`, `quot;`). +/// The `bytes_to_skip` includes the semicolon. +fn match_builtin_entity(bytes: &[u8]) -> Option<(&'static str, usize)> { + // Use first byte to narrow the match + match bytes.first() { + Some(b'a') => { + if bytes.starts_with(b"amp;") { + return Some(("&", 4)); + } + if bytes.starts_with(b"apos;") { + return Some(("'", 5)); + } + None + } + Some(b'l') => { + if bytes.starts_with(b"lt;") { + return Some(("<", 3)); + } + None + } + Some(b'g') => { + if bytes.starts_with(b"gt;") { + return Some((">", 3)); + } + None + } + Some(b'q') => { + if bytes.starts_with(b"quot;") { + return Some(("\"", 5)); + } + None + } + _ => None, + } +} + +/// Checks whether a chunk of text contains any characters that are not valid +/// XML `Char`s per XML 1.0 §2.2. Returns the first invalid character found +/// (if any), or `None` if the chunk is clean. +pub(crate) fn find_invalid_xml_char(s: &str) -> Option<char> { + s.chars().find(|&ch| !is_xml_char(ch)) +} + +/// Returns `true` if a byte slice might contain invalid XML characters. +/// This is a fast pre-check: if all bytes are >= 0x20 (and no DEL 0x7F), +/// the content is guaranteed valid for the ASCII range. Also detects +/// the UTF-8 encodings of U+FFFE and U+FFFF (0xEF 0xBF 0xBE/0xBF). +pub(crate) fn may_contain_invalid_xml_chars(bytes: &[u8]) -> bool { + let len = bytes.len(); + let mut i = 0; + while i < len { + let b = bytes[i]; + if (b < 0x20 && b != b'\t' && b != b'\n' && b != b'\r') || b == 0x7F { + return true; + } + if b == 0xEF + && i + 2 < len + && bytes[i + 1] == 0xBF + && (bytes[i + 2] == 0xBE || bytes[i + 2] == 0xBF) + { + return true; + } + i += 1; + } + false +} + +/// Splits a qualified name into optional prefix and local part. +/// +/// `"foo:bar"` → `(Some("foo"), "bar")` +/// `"bar"` → `(None, "bar")` +pub(crate) fn split_name(name: &str) -> (Option<&str>, &str) { + match name.find(':') { + Some(pos) => (Some(&name[..pos]), &name[pos + 1..]), + None => (None, name), + } +} + +/// Splits an owned `QName` into `(Option<prefix>, local_name)`, reusing the +/// original `String` buffer for the prefix portion when possible. +/// +/// `"foo:bar".to_string()` → `(Some("foo"), "bar")` — prefix reuses the +/// original allocation (truncated), local is a new `String`. +/// +/// `"bar".to_string()` → `(None, "bar")` — no allocation, returns the +/// original `String` as the local name. +pub(crate) fn split_owned_name(name: String) -> (Option<String>, String) { + match name.find(':') { + Some(pos) => { + let local = name[pos + 1..].to_string(); + let mut prefix = name; + prefix.truncate(pos); + (Some(prefix), local) + } + None => (None, name), + } +} + +/// Validates that a name is a legal `QName` per Namespaces in XML 1.0 §4. +/// +/// A `QName` has at most one colon, and neither prefix nor local part may be +/// empty. Returns an error message if invalid, or `None` if valid. +#[allow(dead_code)] +pub(crate) fn validate_qname(name: &str) -> Option<&'static str> { + let colon_count = name.chars().filter(|&c| c == ':').count(); + if colon_count > 1 { + return Some("QName contains multiple colons"); + } + if colon_count == 1 && (name.starts_with(':') || name.ends_with(':')) { + return Some("QName has empty prefix or local part"); + } + None +} + +/// The well-known xmlns namespace URI. +pub(crate) const XMLNS_NAMESPACE: &str = "http://www.w3.org/2000/xmlns/"; + +/// Returns `true` if `c` is a valid `PubidChar` per XML 1.0 §2.3 `[13]`. +/// +/// `PubidChar ::= #x20 | #xD | #xA | [a-zA-Z0-9] | [-'()+,./:=?;!*#@$_%]` +pub(crate) fn is_pubid_char(c: char) -> bool { + matches!(c, + ' ' | '\r' | '\n' | + 'a'..='z' | 'A'..='Z' | '0'..='9' | + '-' | '\'' | '(' | ')' | '+' | ',' | '.' | '/' | ':' | + '=' | '?' | ';' | '!' | '*' | '#' | '@' | '$' | '_' | '%' + ) +} + +/// Validates that a string contains only valid `PubidChar`s. +/// +/// Returns `None` if valid, or a descriptive error message if not. +pub(crate) fn validate_pubid(s: &str) -> Option<String> { + for c in s.chars() { + if !is_pubid_char(c) { + return Some(format!( + "invalid character '{}' (U+{:04X}) in public ID", + c.escape_default(), + c as u32 + )); + } + } + None +} + +// ------------------------------------------------------------------------- +// Position checkpointing (for backtracking) +// ------------------------------------------------------------------------- + +/// A snapshot of the input position (byte offset, line, column). +/// +/// Obtained via [`ParserInput::save_position`] and restored via +/// [`ParserInput::restore_position`]. Used by error-tolerant parsers +/// (e.g., the HTML parser) that need to backtrack when a speculative +/// parse fails. +#[derive(Debug, Clone, Copy)] +#[allow(dead_code)] +pub(crate) struct SavedPosition { + pos: usize, + line: u32, + column: u32, +} + +// ------------------------------------------------------------------------- +// ParserInput +// ------------------------------------------------------------------------- + +/// Information about an externally-declared entity (SYSTEM/PUBLIC). +#[derive(Debug, Clone)] +pub(crate) struct ExternalEntityInfo { + /// The SYSTEM identifier (URI) from the entity declaration. + pub system_id: String, + /// The PUBLIC identifier, if any. + pub public_id: Option<String>, +} + +/// Shared low-level input state for all parsers. +/// +/// Tracks the byte stream, position (line/column/offset), nesting depth, +/// entity expansion count, and accumulated diagnostics. All parsers +/// (tree-building, SAX, reader, HTML) compose this struct rather than +/// reimplementing input handling. +pub(crate) struct ParserInput<'a> { + /// The input bytes (must be valid UTF-8). + input: &'a [u8], + + /// Current byte offset in `input`. + pos: usize, + + /// Current line number (1-based). + line: u32, + + /// Current column number (1-based). + column: u32, + + /// Current element nesting depth. + depth: u32, + + /// Maximum allowed nesting depth. + max_depth: u32, + + /// Maximum allowed name length in bytes. + max_name_length: usize, + + /// Number of entity references expanded so far. + pub(crate) entity_expansions: u32, + + /// Maximum allowed entity expansions. + max_entity_expansions: u32, + + /// Whether the parser is in error-recovery mode. + recover: bool, + + /// Accumulated diagnostics (warnings and recoverable errors). + pub(crate) diagnostics: Vec<ParseDiagnostic>, + + /// Entity replacement values from the DTD internal subset. + /// Populated after parsing `<!DOCTYPE root [ ... ]>`. + pub(crate) entity_map: HashMap<String, String>, + + /// External entity declarations keyed by entity name, storing the + /// SYSTEM and PUBLIC identifiers. Used for the entity resolver and + /// to enforce WFC: No External Entity References in attribute values. + pub(crate) entity_external: HashMap<String, ExternalEntityInfo>, + + /// Whether the DTD internal subset contained parameter entity references. + /// Per XML 1.0 §4.1 WFC: Entity Declared, undeclared entity references + /// are only well-formedness errors if the document has no parameter entity + /// references in the internal subset. + pub(crate) has_pe_references: bool, + + /// Whether the document has an external DTD subset (SYSTEM or PUBLIC). + /// Per XML 1.0 §4.1 WFC: Entity Declared, undeclared entity references + /// are not well-formedness errors when the document references an + /// external DTD subset that was not read. + pub(crate) has_external_dtd: bool, + + /// Entities whose content production has already been validated. + /// Prevents redundant re-validation on repeated references. + validated_entities: std::collections::HashSet<String>, + + /// Optional callback for resolving external entities. + entity_resolver: Option<EntityResolver>, +} + +impl<'a> ParserInput<'a> { + /// Creates a new `ParserInput` from a UTF-8 string with default limits. + pub fn new(input: &'a str) -> Self { + Self { + input: input.as_bytes(), + pos: 0, + line: 1, + column: 1, + depth: 0, + max_depth: DEFAULT_MAX_DEPTH, + max_name_length: DEFAULT_MAX_NAME_LENGTH, + entity_expansions: 0, + max_entity_expansions: DEFAULT_MAX_ENTITY_EXPANSIONS, + recover: false, + diagnostics: Vec::new(), + entity_map: HashMap::new(), + entity_external: HashMap::new(), + has_pe_references: false, + has_external_dtd: false, + validated_entities: std::collections::HashSet::new(), + entity_resolver: None, + } + } + + /// Sets the external entity resolver. + pub fn set_entity_resolver(&mut self, resolver: Option<EntityResolver>) { + self.entity_resolver = resolver; + } + + /// Sets the maximum nesting depth. + pub fn set_max_depth(&mut self, max: u32) { + self.max_depth = max; + } + + /// Sets the maximum name length. + pub fn set_max_name_length(&mut self, max: usize) { + self.max_name_length = max; + } + + /// Sets the maximum entity expansion count. + pub fn set_max_entity_expansions(&mut self, max: u32) { + self.max_entity_expansions = max; + } + + /// Enables or disables error-recovery mode. + pub fn set_recover(&mut self, recover: bool) { + self.recover = recover; + } + + /// Returns whether recovery mode is enabled. + pub fn recover(&self) -> bool { + self.recover + } + + // -- Depth tracking -- + + /// Increments the nesting depth. Returns an error if the limit is exceeded. + pub fn increment_depth(&mut self) -> Result<(), ParseError> { + self.depth += 1; + if self.depth > self.max_depth { + return Err(self.fatal(format!( + "maximum nesting depth exceeded ({})", + self.max_depth + ))); + } + Ok(()) + } + + /// Decrements the nesting depth (saturating at 0). + pub fn decrement_depth(&mut self) { + self.depth = self.depth.saturating_sub(1); + } + + /// Returns the current nesting depth. + pub fn depth(&self) -> u32 { + self.depth + } + + /// Sets the current nesting depth. + /// + /// Used when a nested sub-parser (entity replacement text) must inherit + /// the outer parser's depth so the total element nesting across entity + /// expansions stays bounded by `max_depth`. + pub fn set_depth(&mut self, depth: u32) { + self.depth = depth; + } + + // -- Position queries -- + + /// Returns the current source location. + pub fn location(&self) -> SourceLocation { + SourceLocation { + line: self.line, + column: self.column, + byte_offset: self.pos, + } + } + + /// Returns `true` if all input has been consumed. + #[inline] + pub fn at_end(&self) -> bool { + self.pos >= self.input.len() + } + + /// Returns the current byte offset. + #[allow(dead_code)] + #[inline] + pub fn pos(&self) -> usize { + self.pos + } + + /// Returns a slice of the raw input bytes from `start` to `end`. + #[allow(dead_code)] + #[inline] + pub fn slice(&self, start: usize, end: usize) -> &[u8] { + &self.input[start..end] + } + + /// Returns the remaining input bytes from the current position. + #[allow(dead_code)] + #[inline] + pub fn remaining(&self) -> &[u8] { + &self.input[self.pos..] + } + + /// Saves the current position (byte offset, line, column) so it can be + /// restored later with [`restore_position`]. This is useful for + /// backtracking in error-tolerant parsers (e.g., the HTML parser). + #[allow(dead_code)] + pub fn save_position(&self) -> SavedPosition { + SavedPosition { + pos: self.pos, + line: self.line, + column: self.column, + } + } + + /// Restores a previously saved position. All progress since the + /// [`save_position`] call is discarded. + #[allow(dead_code)] + pub fn restore_position(&mut self, saved: SavedPosition) { + self.pos = saved.pos; + self.line = saved.line; + self.column = saved.column; + } + + // -- Peek operations -- + + /// Returns the byte at the current position without consuming it. + #[inline] + pub fn peek(&self) -> Option<u8> { + self.input.get(self.pos).copied() + } + + /// Returns the byte at `current_position + offset` without consuming. + pub fn peek_at(&self, offset: usize) -> Option<u8> { + self.input.get(self.pos + offset).copied() + } + + /// Returns the character at the current position without consuming it. + /// + /// Uses an ASCII fast path (single byte check) for the common case, + /// and decodes only the minimal 1–4 bytes needed for multi-byte UTF-8. + #[inline] + pub fn peek_char(&self) -> Option<char> { + if self.pos >= self.input.len() { + return None; + } + let first = self.input[self.pos]; + // Fast path: ASCII (covers 95%+ of XML content) + if first < 0x80 { + return Some(first as char); + } + // Slow path: multi-byte UTF-8 — decode only the needed bytes + let len = match first { + 0xC0..=0xDF => 2, + 0xE0..=0xEF => 3, + 0xF0..=0xF7 => 4, + _ => return None, // invalid UTF-8 lead byte + }; + let remaining = &self.input[self.pos..]; + if remaining.len() < len { + return None; + } + std::str::from_utf8(&remaining[..len]) + .ok() + .and_then(|s| s.chars().next()) + } + + // -- Advance operations -- + + /// Advances the position by `count` bytes, updating line/column. + #[inline] + pub fn advance(&mut self, count: usize) { + // Fast path for count == 1 (the most common case from expect_byte, etc.) + if count == 1 { + if self.pos < self.input.len() { + if self.input[self.pos] == b'\n' { + self.line += 1; + self.column = 1; + } else { + self.column += 1; + } + self.pos += 1; + } + return; + } + self.advance_counting_lines(count.min(self.input.len() - self.pos)); + } + + /// Advances by one UTF-8 character, updating line/column. + #[inline] + pub fn advance_char(&mut self, ch: char) { + let len = ch.len_utf8(); + if ch == '\n' { + self.line += 1; + self.column = 1; + } else { + self.column += 1; + } + self.pos += len; + } + + /// Consumes and returns the next byte, or returns an error at EOF. + #[inline] + pub fn next_byte(&mut self) -> Result<u8, ParseError> { + if self.at_end() { + return Err(self.fatal("unexpected end of input")); + } + let b = self.input[self.pos]; + self.advance(1); + Ok(b) + } + + /// Consumes and returns the next character with `\r\n` normalization + /// (XML 1.0 §2.11) and character validation (XML 1.0 §2.2). + #[inline] + pub fn next_char(&mut self) -> Result<char, ParseError> { + if self.pos >= self.input.len() { + return Err(self.fatal("unexpected end of input")); + } + let first = self.input[self.pos]; + // Fast path: ASCII (covers 95%+ of XML content) + if first < 0x80 { + self.pos += 1; + if first == b'\n' { + self.line += 1; + self.column = 1; + } else if first == b'\r' { + // \r\n → \n normalization (XML 1.0 §2.11) + self.line += 1; + self.column = 1; + if self.pos < self.input.len() && self.input[self.pos] == b'\n' { + self.pos += 1; + } + return Ok('\n'); + } else { + self.column += 1; + // Validate ASCII control chars (XML 1.0 §2.2: only #x9, #xA, #xD, #x20+ allowed) + if first < 0x20 && first != b'\t' { + let ch = first as char; + if self.recover { + self.push_diagnostic( + ErrorSeverity::Error, + format!("invalid XML character: U+{:04X}", ch as u32), + ); + } else { + return Err( + self.fatal(format!("invalid XML character: U+{:04X}", ch as u32)) + ); + } + } + } + return Ok(first as char); + } + // Slow path: multi-byte UTF-8 + let ch = self + .peek_char() + .ok_or_else(|| self.fatal("unexpected end of input"))?; + self.advance_char(ch); + // Validate against XML 1.0 §2.2 Char production + if !is_xml_char(ch) { + if self.recover { + self.push_diagnostic( + ErrorSeverity::Error, + format!("invalid XML character: U+{:04X}", ch as u32), + ); + } else { + return Err(self.fatal(format!("invalid XML character: U+{:04X}", ch as u32))); + } + } + Ok(ch) + } + + // -- Bulk scanning -- + + /// Scans forward from the current position to find the next character data + /// boundary (`<`, `&`, or `]]>`). Returns the number of safe bytes that + /// can be consumed as plain text content. + #[inline] + pub fn scan_char_data(&self) -> usize { + let bytes = &self.input[self.pos..]; + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + match b { + b'<' | b'&' => return i, + b']' if i + 2 < bytes.len() && bytes[i + 1] == b']' && bytes[i + 2] == b'>' => { + return i; + } + _ => { + // Stop at invalid XML control characters (0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F) + // so they fall through to next_char() which validates and reports errors. + if b < 0x20 && b != b'\t' && b != b'\n' && b != b'\r' { + return i; + } + i += 1; + } + } + } + bytes.len() + } + + /// Scans forward to find the next attribute value delimiter: the given + /// `quote` byte, `&`, `<`, or any invalid XML control character. + /// Returns the number of safe bytes before the delimiter. + /// Avoids the 256-byte lookup table of `scan_until_any`. + #[inline] + pub fn scan_attr_value(&self, quote: u8) -> usize { + let bytes = &self.input[self.pos..]; + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if b == quote || b == b'&' || b == b'<' { + return i; + } + // Stop at invalid XML control characters (0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F) + // so they fall through to next_char() which validates and reports errors. + if b < 0x20 && b != b'\t' && b != b'\n' && b != b'\r' { + return i; + } + i += 1; + } + bytes.len() + } + + /// Scans forward to find the next occurrence of any of the given marker + /// bytes. Returns the number of bytes before the marker. + /// + /// Uses a 256-byte lookup table for O(1) per-byte matching instead of + /// linear `contains()` on the marker slice. + #[allow(dead_code)] + pub fn scan_until_any(&self, markers: &[u8]) -> usize { + let mut marker_set = [false; 256]; + for &m in markers { + marker_set[m as usize] = true; + } + let bytes = &self.input[self.pos..]; + for (i, &b) in bytes.iter().enumerate() { + if marker_set[b as usize] { + return i; + } + } + bytes.len() + } + + /// Scans forward to find a 2-byte terminator sequence (e.g., `?>`, `--`). + /// Returns the number of bytes before the first byte of the terminator, + /// or `None` if not found. + pub fn scan_for_2byte_terminator(&self, t0: u8, t1: u8) -> Option<usize> { + let bytes = &self.input[self.pos..]; + if bytes.len() < 2 { + return None; + } + let mut i = 0; + let end = bytes.len() - 1; + while i < end { + if bytes[i] == t0 && bytes[i + 1] == t1 { + return Some(i); + } + i += 1; + } + None + } + + /// Scans forward to find a 3-byte terminator sequence (e.g., `-->`, `]]>`). + /// Returns the number of bytes before the first byte of the terminator, + /// or `None` if not found. + pub fn scan_for_3byte_terminator(&self, t0: u8, t1: u8, t2: u8) -> Option<usize> { + let bytes = &self.input[self.pos..]; + if bytes.len() < 3 { + return None; + } + let mut i = 0; + let end = bytes.len() - 2; + while i < end { + if bytes[i] == t0 && bytes[i + 1] == t1 && bytes[i + 2] == t2 { + return Some(i); + } + i += 1; + } + None + } + + /// Advances the position by `count` bytes, tracking line/column numbers + /// in a single pass. More efficient than calling `advance(1)` in a loop + /// for bulk text consumption. + #[inline] + pub fn advance_counting_lines(&mut self, count: usize) { + let end = self.pos + count; + let slice = &self.input[self.pos..end]; + // Fast path: if no newlines in the slice, just bump column + if slice.contains(&b'\n') { + for &b in slice { + if b == b'\n' { + self.line += 1; + self.column = 1; + } else { + self.column += 1; + } + } + } else { + #[allow(clippy::cast_possible_truncation)] + { + self.column += count as u32; + } + } + self.pos = end; + } + + // -- Expect operations -- + + /// Consumes the next byte and asserts it matches `expected`. + #[inline] + pub fn expect_byte(&mut self, expected: u8) -> Result<(), ParseError> { + let b = self.next_byte()?; + if b != expected { + return Err(self.fatal(format!( + "expected '{}', found '{}'", + expected as char, b as char + ))); + } + Ok(()) + } + + /// Consumes bytes and asserts they match the `expected` sequence. + #[inline] + pub fn expect_str(&mut self, expected: &[u8]) -> Result<(), ParseError> { + if self.pos + expected.len() > self.input.len() { + return Err(self.fatal("unexpected end of input")); + } + if &self.input[self.pos..self.pos + expected.len()] == expected { + self.advance_counting_lines(expected.len()); + } else { + // Fall back to per-byte for precise error reporting + for &b in expected { + self.expect_byte(b)?; + } + } + Ok(()) + } + + // -- Lookahead -- + + /// Returns `true` if the remaining input starts with `s`. + #[inline] + pub fn looking_at(&self, s: &[u8]) -> bool { + self.input[self.pos..].starts_with(s) + } + + /// Case-insensitive lookahead check. Returns `true` if the remaining + /// input starts with `expected` when compared case-insensitively (ASCII). + #[allow(dead_code)] + pub fn looking_at_ci(&self, expected: &[u8]) -> bool { + if self.pos + expected.len() > self.input.len() { + return false; + } + self.input[self.pos..self.pos + expected.len()].eq_ignore_ascii_case(expected) + } + + // -- Whitespace -- + + /// Skips whitespace characters. Returns `true` if any were consumed. + #[inline] + pub fn skip_whitespace(&mut self) -> bool { + let start = self.pos; + while self.pos < self.input.len() { + match self.input[self.pos] { + b'\n' => { + self.line += 1; + self.column = 1; + self.pos += 1; + } + b' ' | b'\t' | b'\r' => { + self.column += 1; + self.pos += 1; + } + _ => break, + } + } + self.pos > start + } + + /// Consumes and returns any whitespace characters at the current position. + /// + /// Returns the consumed whitespace as a `&str` (empty if no whitespace). + pub fn consume_whitespace(&mut self) -> &str { + let start = self.pos; + while self.pos < self.input.len() { + match self.input[self.pos] { + b'\n' => { + self.line += 1; + self.column = 1; + self.pos += 1; + } + b' ' | b'\t' | b'\r' => { + self.column += 1; + self.pos += 1; + } + _ => break, + } + } + // Whitespace bytes are valid ASCII/UTF-8, so this conversion is safe. + std::str::from_utf8(&self.input[start..self.pos]).unwrap_or_default() + } + + /// Skips whitespace, returning an error if none is found. + pub fn skip_whitespace_required(&mut self) -> Result<(), ParseError> { + if !self.skip_whitespace() { + return Err(self.fatal("whitespace required")); + } + Ok(()) + } + + // -- Take while -- + + /// Consumes bytes while `pred` returns `true` and returns the string. + pub fn take_while(&mut self, pred: impl Fn(u8) -> bool) -> String { + let start = self.pos; + while self.pos < self.input.len() && pred(self.input[self.pos]) { + if self.input[self.pos] == b'\n' { + self.line += 1; + self.column = 1; + } else { + self.column += 1; + } + self.pos += 1; + } + // The predicates used (ascii_digit, ascii_hexdigit) only match ASCII, + // so the consumed range is always valid UTF-8. + std::str::from_utf8(&self.input[start..self.pos]) + .unwrap_or("") + .to_string() + } + + // -- Name parsing (XML 1.0 §2.3) -- + + /// Parses an XML `Name` per XML 1.0 §2.3 production `[5]`. + /// + /// A `Name` starts with a `NameStartChar` followed by zero or more + /// `NameChar`s. Returns an error if the name is empty or starts with + /// an invalid character. + /// + /// Uses an ASCII fast path that scans name characters as bytes, + /// avoiding per-character UTF-8 decoding for the common case. + #[inline] + pub fn parse_name(&mut self) -> Result<String, ParseError> { + let start = self.pos; + if self.pos >= self.input.len() { + return Err(self.fatal("expected name, found end of input")); + } + + let first = self.input[self.pos]; + + // ASCII fast path: most XML names are pure ASCII + if is_ascii_name_start(first) { + self.pos += 1; + self.column += 1; + while self.pos < self.input.len() && is_ascii_name_char(self.input[self.pos]) { + self.pos += 1; + self.column += 1; + } + // Check if we stopped at a non-ASCII byte (need slow path) + if self.pos >= self.input.len() || self.input[self.pos] < 0x80 { + let len = self.pos - start; + if len > self.max_name_length { + return Err(self.fatal(format!( + "name length ({len}) exceeds maximum ({})", + self.max_name_length + ))); + } + // Input is guaranteed valid UTF-8 and we only consumed ASCII bytes + let name = std::str::from_utf8(&self.input[start..self.pos]) + .map_err(|_| self.fatal("invalid UTF-8 in name"))?; + return Ok(name.to_string()); + } + // Fall through: hit a non-ASCII continuation byte, continue + // with the char-by-char path below. + } else { + // Non-ASCII first byte or invalid ASCII start char — + // use the standard char-by-char path. + let ch = self + .peek_char() + .ok_or_else(|| self.fatal("expected name"))?; + if !is_name_start_char(ch) { + return Err(self.fatal(format!("invalid name start character: '{ch}'"))); + } + self.advance_char(ch); + } + + // Slow path: handles non-ASCII name characters + while let Some(ch) = self.peek_char() { + if is_name_char(ch) { + self.advance_char(ch); + } else { + break; + } + } + + let len = self.pos - start; + if len > self.max_name_length { + return Err(self.fatal(format!( + "name length ({len}) exceeds maximum ({})", + self.max_name_length + ))); + } + + let name = std::str::from_utf8(&self.input[start..self.pos]) + .map_err(|_| self.fatal("invalid UTF-8 in name"))?; + Ok(name.to_string()) + } + + /// Parses a name from the input and checks whether it matches `expected`. + /// + /// Advances past the parsed name in all cases. Returns `Ok(None)` if the + /// name matches, or `Ok(Some(parsed_name))` if it doesn't (the caller + /// gets the actual name for error messages). This avoids allocating a + /// `String` in the happy path (matching names). + #[allow(dead_code)] + pub fn parse_name_eq(&mut self, expected: &str) -> Result<Option<String>, ParseError> { + let start = self.pos; + if self.pos >= self.input.len() { + return Err(self.fatal("expected name, found end of input")); + } + + let first = self.input[self.pos]; + + // ASCII fast path + if is_ascii_name_start(first) { + self.pos += 1; + self.column += 1; + while self.pos < self.input.len() && is_ascii_name_char(self.input[self.pos]) { + self.pos += 1; + self.column += 1; + } + if self.pos >= self.input.len() || self.input[self.pos] < 0x80 { + let len = self.pos - start; + if len > self.max_name_length { + return Err(self.fatal(format!( + "name length ({len}) exceeds maximum ({})", + self.max_name_length + ))); + } + // Compare directly against input bytes — no allocation needed + if len == expected.len() && &self.input[start..self.pos] == expected.as_bytes() { + return Ok(None); // match — no allocation + } + let name = std::str::from_utf8(&self.input[start..self.pos]) + .map_err(|_| self.fatal("invalid UTF-8 in name"))?; + return Ok(Some(name.to_string())); + } + // Fall through to slow path for non-ASCII + } else { + let ch = self + .peek_char() + .ok_or_else(|| self.fatal("expected name"))?; + if !is_name_start_char(ch) { + return Err(self.fatal(format!("invalid name start character: '{ch}'"))); + } + self.advance_char(ch); + } + + // Slow path for non-ASCII names + while let Some(ch) = self.peek_char() { + if is_name_char(ch) { + self.advance_char(ch); + } else { + break; + } + } + + let len = self.pos - start; + if len > self.max_name_length { + return Err(self.fatal(format!( + "name length ({len}) exceeds maximum ({})", + self.max_name_length + ))); + } + + if len == expected.len() && &self.input[start..self.pos] == expected.as_bytes() { + return Ok(None); + } + let name = std::str::from_utf8(&self.input[start..self.pos]) + .map_err(|_| self.fatal("invalid UTF-8 in name"))?; + Ok(Some(name.to_string())) + } + + /// Parses a name and checks whether it matches the given prefix + local + /// name parts. This avoids needing the full `"prefix:local"` `String` for + /// end tag matching — the caller can pass already-split owned parts. + /// + /// Returns `Ok(None)` on match, or `Ok(Some(parsed_name))` on mismatch. + pub fn parse_name_eq_parts( + &mut self, + prefix: Option<&str>, + local: &str, + ) -> Result<Option<String>, ParseError> { + let start = self.pos; + if self.pos >= self.input.len() { + return Err(self.fatal("expected name, found end of input")); + } + + let first = self.input[self.pos]; + + // ASCII fast path + if is_ascii_name_start(first) { + self.pos += 1; + self.column += 1; + while self.pos < self.input.len() && is_ascii_name_char(self.input[self.pos]) { + self.pos += 1; + self.column += 1; + } + if self.pos >= self.input.len() || self.input[self.pos] < 0x80 { + let len = self.pos - start; + if len > self.max_name_length { + return Err(self.fatal(format!( + "name length ({len}) exceeds maximum ({})", + self.max_name_length + ))); + } + let parsed = &self.input[start..self.pos]; + // Compare against prefix:local parts + let matches = match prefix { + Some(pfx) => { + let expected_len = pfx.len() + 1 + local.len(); + len == expected_len + && parsed[..pfx.len()] == *pfx.as_bytes() + && parsed[pfx.len()] == b':' + && parsed[pfx.len() + 1..] == *local.as_bytes() + } + None => len == local.len() && parsed == local.as_bytes(), + }; + if matches { + return Ok(None); + } + let name = + std::str::from_utf8(parsed).map_err(|_| self.fatal("invalid UTF-8 in name"))?; + return Ok(Some(name.to_string())); + } + // Fall through to slow path for non-ASCII + } else { + let ch = self + .peek_char() + .ok_or_else(|| self.fatal("expected name"))?; + if !is_name_start_char(ch) { + return Err(self.fatal(format!("invalid name start character: '{ch}'"))); + } + self.advance_char(ch); + } + + // Slow path for non-ASCII names + while let Some(ch) = self.peek_char() { + if is_name_char(ch) { + self.advance_char(ch); + } else { + break; + } + } + + let len = self.pos - start; + if len > self.max_name_length { + return Err(self.fatal(format!( + "name length ({len}) exceeds maximum ({})", + self.max_name_length + ))); + } + + let parsed = &self.input[start..self.pos]; + let matches = match prefix { + Some(pfx) => { + let expected_len = pfx.len() + 1 + local.len(); + len == expected_len + && parsed[..pfx.len()] == *pfx.as_bytes() + && parsed[pfx.len()] == b':' + && parsed[pfx.len() + 1..] == *local.as_bytes() + } + None => len == local.len() && parsed == local.as_bytes(), + }; + if matches { + return Ok(None); + } + let name = std::str::from_utf8(parsed).map_err(|_| self.fatal("invalid UTF-8 in name"))?; + Ok(Some(name.to_string())) + } + + // -- Reference parsing (XML 1.0 §4.1) -- + + /// Counts one entity expansion against the security limit. + /// + /// Returns an error if the configured maximum is exceeded. + pub fn count_entity_expansion(&mut self) -> Result<(), ParseError> { + self.entity_expansions += 1; + if self.entity_expansions > self.max_entity_expansions { + return Err(self.fatal(format!( + "entity expansion limit exceeded ({})", + self.max_entity_expansions + ))); + } + Ok(()) + } + + /// Parses an entity or character reference (`&...;`). + /// + /// Handles the five built-in XML entities (`amp`, `lt`, `gt`, `apos`, + /// `quot`) and decimal/hexadecimal character references. + /// + /// # Security + /// + /// Increments the entity expansion counter and returns an error if the + /// limit is exceeded. + #[cfg(test)] + pub fn parse_reference(&mut self) -> Result<String, ParseError> { + let mut buf = String::new(); + self.parse_reference_into(&mut buf)?; + Ok(buf) + } + + /// Parses an entity or character reference and appends the result + /// directly into `buf`, avoiding an intermediate `String` allocation. + /// + /// For builtin entities (`&amp;`, `&lt;`, etc.) and character references + /// (`&#65;`, `&#x41;`), pushes the resolved character directly. For + /// general entities, appends the expanded replacement text. + /// + /// Returns the resolved text as a `&str` slice of `buf` (the portion + /// that was appended), which callers can use for validation. + pub fn parse_reference_into<'b>(&mut self, buf: &'b mut String) -> Result<&'b str, ParseError> { + self.count_entity_expansion()?; + + self.expect_byte(b'&')?; + + // Fast path: recognize builtin entities at byte level + let remaining = &self.input[self.pos..]; + if let Some(result) = match_builtin_entity(remaining) { + let advance_len = result.1; + self.advance_counting_lines(advance_len); + let start = buf.len(); + buf.push_str(result.0); + return Ok(&buf[start..]); + } + + if self.peek() == Some(b'#') { + // Character reference + self.advance(1); + let value = if self.peek() == Some(b'x') { + self.advance(1); + let hex = self.take_while(|b| b.is_ascii_hexdigit()); + if hex.is_empty() { + return Err(self.fatal("empty hex character reference")); + } + u32::from_str_radix(&hex, 16) + .map_err(|_| self.fatal("invalid hex character reference"))? + } else { + let dec = self.take_while(|b| b.is_ascii_digit()); + if dec.is_empty() { + return Err(self.fatal("empty decimal character reference")); + } + dec.parse::<u32>() + .map_err(|_| self.fatal("invalid decimal character reference"))? + }; + self.expect_byte(b';')?; + + let ch = char::from_u32(value) + .ok_or_else(|| self.fatal(format!("invalid character reference: U+{value:04X}")))?; + + if !is_xml_char(ch) { + return Err(self.fatal(format!( + "character reference &#x{value:X}; does not refer to a valid XML character" + ))); + } + + let start = buf.len(); + buf.push(ch); + Ok(&buf[start..]) + } else { + // General entity reference — delegate to parse_reference logic + // (rare path, allocation is acceptable) + let name = self.parse_name()?; + self.expect_byte(b';')?; + + let expanded = match name.as_str() { + "amp" | "lt" | "gt" | "apos" | "quot" => { + unreachable!("builtin entity should be caught by fast path") + } + _ => { + if let Some(info) = self.entity_external.get(&name).cloned() { + if let Some(ref resolver) = self.entity_resolver.clone() { + let request = ExternalEntityRequest { + name: &name, + system_id: &info.system_id, + public_id: info.public_id.as_deref(), + }; + if let Some(resolved) = resolver(request) { + self.expand_entity_text(&resolved)? + } else { + return Err(self.fatal(format!( + "reference to external entity '{name}' is not supported" + ))); + } + } else { + return Err(self.fatal(format!( + "reference to external entity '{name}' is not supported" + ))); + } + } else if let Some(value) = self.entity_map.get(&name).cloned() { + if !self.validated_entities.contains(&name) { + self.validated_entities.insert(name.clone()); + self.validate_entity_content(&name, &value)?; + } + self.expand_entity_text(&value)? + } else if self.recover || self.has_pe_references || self.has_external_dtd { + self.push_diagnostic( + ErrorSeverity::Warning, + format!("unknown entity reference: &{name};"), + ); + String::new() + } else { + return Err(self.fatal(format!("unknown entity reference: &{name};"))); + } + } + }; + let start = buf.len(); + buf.push_str(&expanded); + Ok(&buf[start..]) + } + } + + /// Expands entity and character references in entity replacement text. + /// + /// Per XML 1.0 §4.4, when an entity's replacement text is included, + /// character references and entity references within it are resolved. + /// This method performs that resolution recursively, using the entity + /// map populated from the DTD. + #[allow(clippy::too_many_lines)] + fn expand_entity_text(&mut self, text: &str) -> Result<String, ParseError> { + // Fast path — no references to expand + if !text.contains('&') { + return Ok(text.to_string()); + } + + let bytes = text.as_bytes(); + let mut result = String::with_capacity(text.len()); + let mut i = 0; + let mut in_cdata = false; + + while i < bytes.len() { + // Track CDATA sections — entity references inside CDATA are + // literal text and should not be expanded. + if !in_cdata && i + 8 < bytes.len() && &bytes[i..i + 9] == b"<![CDATA[" { + in_cdata = true; + result.push_str("<![CDATA["); + i += 9; + continue; + } + if in_cdata { + if i + 2 < bytes.len() && &bytes[i..i + 3] == b"]]>" { + in_cdata = false; + result.push_str("]]>"); + i += 3; + } else { + result.push(bytes[i] as char); + i += 1; + } + continue; + } + if bytes[i] == b'&' { + i += 1; + if i < bytes.len() && bytes[i] == b'#' { + // Character reference + i += 1; + let char_val = if i < bytes.len() && bytes[i] == b'x' { + i += 1; + let start = i; + while i < bytes.len() && bytes[i].is_ascii_hexdigit() { + i += 1; + } + let hex = std::str::from_utf8(&bytes[start..i]) + .map_err(|_| self.fatal("invalid UTF-8 in entity value"))?; + u32::from_str_radix(hex, 16) + .map_err(|_| self.fatal("invalid hex character reference"))? + } else { + let start = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + let dec = std::str::from_utf8(&bytes[start..i]) + .map_err(|_| self.fatal("invalid UTF-8 in entity value"))?; + dec.parse::<u32>() + .map_err(|_| self.fatal("invalid decimal character reference"))? + }; + if i >= bytes.len() || bytes[i] != b';' { + return Err(self.fatal("incomplete character reference in entity value")); + } + i += 1; + let ch = char::from_u32(char_val).ok_or_else(|| { + self.fatal(format!("invalid character reference: U+{char_val:04X}")) + })?; + result.push(ch); + } else { + // Entity reference + let start = i; + while i < bytes.len() && bytes[i] != b';' { + i += 1; + } + if i >= bytes.len() { + return Err(self.fatal("incomplete entity reference in entity value")); + } + let name = std::str::from_utf8(&bytes[start..i]) + .map_err(|_| self.fatal("invalid UTF-8 in entity name"))?; + i += 1; // skip ';' + + self.entity_expansions += 1; + if self.entity_expansions > self.max_entity_expansions { + return Err(self.fatal(format!( + "entity expansion limit exceeded ({})", + self.max_entity_expansions + ))); + } + + // Fast path: builtin entities push directly, no allocation + match name { + "amp" => { + result.push('&'); + continue; + } + "lt" => { + result.push('<'); + continue; + } + "gt" => { + result.push('>'); + continue; + } + "apos" => { + result.push('\''); + continue; + } + "quot" => { + result.push('"'); + continue; + } + _ => {} + } + + let expanded = if let Some(info) = self.entity_external.get(name).cloned() { + if let Some(ref resolver) = self.entity_resolver.clone() { + let request = ExternalEntityRequest { + name, + system_id: &info.system_id, + public_id: info.public_id.as_deref(), + }; + if let Some(resolved) = resolver(request) { + self.expand_entity_text(&resolved)? + } else { + return Err(self.fatal(format!( + "reference to external entity '{name}' is not supported" + ))); + } + } else { + return Err(self.fatal(format!( + "reference to external entity '{name}' is not supported" + ))); + } + } else if let Some(value) = self.entity_map.get(name).cloned() { + self.expand_entity_text(&value)? + } else if self.recover || self.has_pe_references || self.has_external_dtd { + self.push_diagnostic( + ErrorSeverity::Warning, + format!("unknown entity reference: &{name};"), + ); + String::new() + } else { + return Err(self.fatal(format!("unknown entity reference: &{name};"))); + }; + result.push_str(&expanded); + } + } else { + // Regular character — copy as-is, handling multi-byte UTF-8 + let start = i; + i += 1; + // Skip continuation bytes + while i < bytes.len() && bytes[i] & 0xC0 == 0x80 { + i += 1; + } + if let Ok(s) = std::str::from_utf8(&bytes[start..i]) { + result.push_str(s); + } + } + } + + Ok(result) + } + + /// Validates that an entity's replacement text matches the XML content + /// production (XML 1.0 §4.3.2 WFC: Parsed Entity). + /// + /// Expands character references in the raw entity value, replaces entity + /// references with placeholders, then wraps in a synthetic root element + /// and parses. If parsing fails, the entity is not well-formed. + fn validate_entity_content(&self, name: &str, raw_value: &str) -> Result<(), ParseError> { + let replacement = crate::validation::dtd::expand_char_refs_only(raw_value); + + // If no '<', the text is just character data — always valid content. + if !replacement.contains('<') { + return Ok(()); + } + + let sanitized = crate::validation::dtd::replace_entity_refs(&replacement); + let wrapped = format!("<_r>{sanitized}</_r>"); + + let options = super::ParseOptions::default(); + if super::parse_str_with_options(&wrapped, &options).is_err() { + return Err(self.fatal(format!( + "entity '{name}' replacement text is not \ + well-formed XML content" + ))); + } + + Ok(()) + } + + // -- Attribute value parsing (XML 1.0 §3.3.3) -- + + /// Parses a quoted attribute value with entity resolution and + /// whitespace normalization. + /// + /// Uses bulk scanning to find the next `&`, `<`, or quote character, + /// then extracts safe chunks with a single `push_str()` instead of + /// processing character by character. + pub fn parse_attribute_value(&mut self) -> Result<String, ParseError> { + let quote = self.next_byte()?; + if quote != b'"' && quote != b'\'' { + return Err(self.fatal("attribute value must be quoted")); + } + + let mut value = String::new(); + loop { + // Bulk scan for the next interesting byte + let safe_len = self.scan_attr_value(quote); + if safe_len > 0 { + let start = self.pos; + let chunk = std::str::from_utf8(&self.input[start..start + safe_len]) + .map_err(|_| self.fatal("invalid UTF-8 in attribute value"))?; + // Fast byte-level pre-check: only validate when bytes suggest + // possible invalid chars (0x7F or U+FFFE/U+FFFF sequences). + if let Some(bad) = may_contain_invalid_xml_chars(chunk.as_bytes()) + .then(|| find_invalid_xml_char(chunk)) + .flatten() + { + if self.recover { + self.push_diagnostic( + ErrorSeverity::Error, + format!("invalid XML character: U+{:04X}", bad as u32), + ); + } else { + return Err( + self.fatal(format!("invalid XML character: U+{:04X}", bad as u32)) + ); + } + } + // Normalize whitespace in chunk (XML 1.0 §3.3.3) + if chunk + .as_bytes() + .iter() + .any(|&b| b == b'\t' || b == b'\n' || b == b'\r') + { + for ch in chunk.chars() { + match ch { + '\t' | '\n' | '\r' => value.push(' '), + _ => value.push(ch), + } + } + } else { + value.push_str(chunk); + } + self.advance_counting_lines(safe_len); + } + + if self.at_end() { + return Err(self.fatal("unexpected end of input in attribute value")); + } + + let b = self.input[self.pos]; + if b == quote { + self.advance(1); + break; + } + if b == b'&' { + // Check if this is a DTD entity reference (not a built-in + // or character reference) by peeking ahead. + let is_custom_entity = self.input.get(self.pos + 1) != Some(&b'#') + && !self.input[self.pos + 1..].starts_with(b"lt;") + && !self.input[self.pos + 1..].starts_with(b"gt;") + && !self.input[self.pos + 1..].starts_with(b"amp;") + && !self.input[self.pos + 1..].starts_with(b"apos;") + && !self.input[self.pos + 1..].starts_with(b"quot;"); + let resolved = self.parse_reference_into(&mut value)?; + // WFC: No < in Attribute Values — entity replacement text + // must not contain '<' (XML 1.0 §3.1). Built-in entity + // &lt; is explicitly excluded from this constraint. + if is_custom_entity && resolved.contains('<') { + return Err( + self.fatal("'<' not allowed in attribute values (from entity expansion)") + ); + } + } else if b == b'<' { + return Err(self.fatal("'<' not allowed in attribute values")); + } else { + let ch = self.next_char()?; + // Normalize whitespace in attribute values (XML 1.0 §3.3.3) + if ch == '\r' || ch == '\n' || ch == '\t' { + value.push(' '); + } else { + value.push(ch); + } + } + } + + Ok(value) + } + + /// Parses a simple quoted value (single or double quotes, no entity + /// resolution). + pub fn parse_quoted_value(&mut self) -> Result<String, ParseError> { + let quote = self.next_byte()?; + if quote != b'"' && quote != b'\'' { + return Err(self.fatal("expected quoted value")); + } + let start = self.pos; + while !self.at_end() && self.peek() != Some(quote) { + self.advance(1); + } + let value = std::str::from_utf8(&self.input[start..self.pos]) + .map_err(|_| self.fatal("invalid UTF-8 in quoted value"))? + .to_string(); + self.expect_byte(quote)?; + Ok(value) + } + + // -- Error helpers -- + + /// Creates a fatal `ParseError` at the current location. + pub fn fatal(&self, message: impl Into<String>) -> ParseError { + ParseError { + message: message.into(), + location: self.location(), + diagnostics: self.diagnostics.clone(), + } + } + + /// Appends a diagnostic (warning or recoverable error) to the list. + pub fn push_diagnostic(&mut self, severity: ErrorSeverity, message: String) { + self.diagnostics.push(ParseDiagnostic { + severity, + message, + location: self.location(), + }); + } +} + +// ------------------------------------------------------------------------- +// Namespace resolver +// ------------------------------------------------------------------------- + +/// Manages namespace scope for XML parsers. +/// +/// Maintains a stack of namespace binding frames that mirrors the element +/// nesting. Each frame contains the `xmlns` declarations introduced on +/// that element. A `HashMap` cache provides O(1) namespace resolution +/// instead of walking the stack. +pub(crate) struct NamespaceResolver { + /// Stack of namespace binding frames. Each frame is a `Vec` of + /// `(prefix, uri)` pairs where a `None` prefix represents the + /// default namespace. + stack: Vec<Vec<(Option<String>, String)>>, + /// O(1) lookup cache for the default namespace (prefix = None). + default_ns: Option<String>, + /// O(1) lookup cache for prefixed namespaces. Uses `String` keys so + /// lookups with `&str` work via the `Borrow` trait without allocation. + prefixed_ns: HashMap<String, String>, +} + +/// The well-known XML namespace URI, pre-bound to the `xml` prefix. +pub(crate) const XML_NAMESPACE: &str = "http://www.w3.org/XML/1998/namespace"; + +impl NamespaceResolver { + /// Creates a new resolver with the `xml` prefix pre-bound. + pub fn new() -> Self { + let initial = vec![(Some("xml".to_string()), XML_NAMESPACE.to_string())]; + let mut prefixed_ns = HashMap::new(); + prefixed_ns.insert("xml".to_string(), XML_NAMESPACE.to_string()); + Self { + stack: vec![initial], + default_ns: None, + prefixed_ns, + } + } + + /// Pushes a new (empty) namespace scope for an element. + pub fn push_scope(&mut self) { + self.stack.push(Vec::new()); + } + + /// Pops the current namespace scope, restoring previous bindings. + pub fn pop_scope(&mut self) { + if let Some(bindings) = self.stack.pop() { + for (prefix, _uri) in bindings.iter().rev() { + // Find previous binding in remaining stack + let prev = self + .stack + .iter() + .rev() + .flat_map(|frame| frame.iter().rev()) + .find(|(p, _)| p == prefix) + .map(|(_, u)| u.clone()); + match prefix { + None => { + self.default_ns = prev; + } + Some(pfx) => { + if let Some(prev_uri) = prev { + self.prefixed_ns.insert(pfx.clone(), prev_uri); + } else { + self.prefixed_ns.remove(pfx); + } + } + } + } + } + } + + /// Binds a namespace prefix to a URI in the current scope. + /// + /// Use `prefix = None` for the default namespace (`xmlns="..."`). + pub fn bind(&mut self, prefix: Option<String>, uri: String) { + if let Some(frame) = self.stack.last_mut() { + frame.push((prefix.clone(), uri.clone())); + } + match prefix { + None => { + self.default_ns = Some(uri); + } + Some(pfx) => { + self.prefixed_ns.insert(pfx, uri); + } + } + } + + /// Resolves a namespace prefix to its URI in O(1) time. + /// + /// Use `prefix = None` to resolve the default namespace. + pub fn resolve(&self, prefix: Option<&str>) -> Option<&str> { + match prefix { + None => self.default_ns.as_deref().filter(|s| !s.is_empty()), + Some(pfx) => self + .prefixed_ns + .get(pfx) + .map(String::as_str) + .filter(|s| !s.is_empty()), + } + } +} + +// ------------------------------------------------------------------------- +// Common XML parsing helpers +// ------------------------------------------------------------------------- + +/// Parses an XML comment (`<!-- ... -->`), returning the content text. +/// +/// The opening `<!--` must not have been consumed yet. +/// +/// See XML 1.0 §2.5 production `[15]`. +pub(crate) fn parse_comment_content(input: &mut ParserInput<'_>) -> Result<String, ParseError> { + input.expect_str(b"<!--")?; + + // Bulk scan for `--` (which is either `-->` end or illegal `--`) + let mut content = String::new(); + loop { + match input.scan_for_2byte_terminator(b'-', b'-') { + Some(safe_len) => { + // Copy everything before the `--` + if safe_len > 0 { + let start = input.pos(); + // Validate XML chars using byte-level pre-check + let has_bad = + may_contain_invalid_xml_chars(input.slice(start, start + safe_len)); + let chunk = std::str::from_utf8(input.slice(start, start + safe_len)) + .map_err(|_| input.fatal("invalid UTF-8 in comment"))? + .to_string(); + if has_bad { + if let Some(bad) = find_invalid_xml_char(&chunk) { + if input.recover() { + input.push_diagnostic( + ErrorSeverity::Error, + format!("invalid XML character: U+{:04X}", bad as u32), + ); + } else { + return Err(input.fatal(format!( + "invalid XML character: U+{:04X}", + bad as u32 + ))); + } + } + } + content.push_str(&chunk); + input.advance_counting_lines(safe_len); + } + // Check if it's `-->` (end of comment) or just `--` + if input.looking_at(b"-->") { + input.advance_counting_lines(3); + break; + } + // Bare `--` inside comment + if input.recover() { + input.push_diagnostic( + ErrorSeverity::Error, + "'--' not allowed inside comments".to_string(), + ); + content.push_str("--"); + input.advance_counting_lines(2); + } else { + return Err(input.fatal("'--' not allowed inside comments")); + } + } + None => { + return Err(input.fatal("unexpected end of input in comment")); + } + } + } + + Ok(content) +} + +/// Parses a CDATA section (`<![CDATA[ ... ]]>`), returning the content text. +/// +/// The opening `<![CDATA[` must not have been consumed yet. +/// +/// See XML 1.0 §2.7 production `[18]`. +pub(crate) fn parse_cdata_content(input: &mut ParserInput<'_>) -> Result<String, ParseError> { + input.expect_str(b"<![CDATA[")?; + + // Bulk scan for `]]>` terminator + match input.scan_for_3byte_terminator(b']', b']', b'>') { + Some(safe_len) => { + let start = input.pos(); + let has_bad = may_contain_invalid_xml_chars(input.slice(start, start + safe_len)); + let content = std::str::from_utf8(input.slice(start, start + safe_len)) + .map_err(|_| input.fatal("invalid UTF-8 in CDATA section"))? + .to_string(); + if has_bad { + if let Some(bad) = find_invalid_xml_char(&content) { + if input.recover() { + input.push_diagnostic( + ErrorSeverity::Error, + format!("invalid XML character: U+{:04X}", bad as u32), + ); + } else { + return Err( + input.fatal(format!("invalid XML character: U+{:04X}", bad as u32)) + ); + } + } + } + input.advance_counting_lines(safe_len + 3); // skip content + ]]> + Ok(content) + } + None => Err(input.fatal("unexpected end of input in CDATA section")), + } +} + +/// Parses a processing instruction (`<?target data?>`), returning +/// `(target, optional_data)`. +/// +/// The opening `<?` must not have been consumed yet. +/// +/// See XML 1.0 §2.6 production `[16]`. +pub(crate) fn parse_pi_content( + input: &mut ParserInput<'_>, +) -> Result<(String, Option<String>), ParseError> { + input.expect_str(b"<?")?; + let target = input.parse_name()?; + + // "xml" (case-insensitive) is reserved for the XML declaration + if target.eq_ignore_ascii_case("xml") { + return Err(input.fatal("PI target 'xml' is reserved")); + } + + // Namespaces in XML 1.0 §3: PI targets must be NCNames (no colons). + if target.contains(':') { + return Err(input.fatal("PI target must not contain a colon")); + } + + let data = if input.skip_whitespace() { + // Bulk scan for `?>` terminator + match input.scan_for_2byte_terminator(b'?', b'>') { + Some(data_len) => { + let start = input.pos(); + let has_bad = may_contain_invalid_xml_chars(input.slice(start, start + data_len)); + let data = std::str::from_utf8(input.slice(start, start + data_len)) + .map_err(|_| input.fatal("invalid UTF-8 in processing instruction"))? + .to_string(); + if has_bad { + if let Some(bad) = find_invalid_xml_char(&data) { + if input.recover() { + input.push_diagnostic( + ErrorSeverity::Error, + format!("invalid XML character: U+{:04X}", bad as u32), + ); + } else { + return Err( + input.fatal(format!("invalid XML character: U+{:04X}", bad as u32)) + ); + } + } + } + input.advance_counting_lines(data_len + 2); // skip data + ?> + if data.is_empty() { + None + } else { + Some(data) + } + } + None => { + return Err(input.fatal("unexpected end of input in processing instruction")); + } + } + } else { + input.expect_str(b"?>")?; + None + }; + + Ok((target, data)) +} + +/// Parsed XML declaration data. +#[derive(Debug, Clone)] +pub(crate) struct XmlDeclaration { + /// XML version (e.g. `"1.0"`). + pub version: String, + /// Optional encoding declaration. + pub encoding: Option<String>, + /// Optional standalone declaration. + pub standalone: Option<bool>, +} + +/// Parses an XML declaration (`<?xml version="1.0" ...?>`), returning the +/// parsed version, encoding, and standalone values. +/// +/// The opening `<?xml ` must not have been consumed yet (but should be +/// verified by the caller via `looking_at`). +/// +/// See XML 1.0 §2.8 production `[23]`. +pub(crate) fn parse_xml_decl(input: &mut ParserInput<'_>) -> Result<XmlDeclaration, ParseError> { + input.expect_str(b"<?xml")?; + input.skip_whitespace_required()?; + + // version is required + input.expect_str(b"version")?; + input.skip_whitespace(); + input.expect_byte(b'=')?; + input.skip_whitespace(); + let version = input.parse_quoted_value()?; + + // XML 1.0 §2.8: VersionNum ::= '1.' [0-9]+ + if !is_valid_version_num(&version) { + return Err(input.fatal(format!("invalid version number: '{version}'"))); + } + + // encoding is optional + let had_ws = input.skip_whitespace(); + let encoding = if input.looking_at(b"encoding") { + if !had_ws { + return Err(input.fatal("whitespace required before encoding")); + } + input.expect_str(b"encoding")?; + input.skip_whitespace(); + input.expect_byte(b'=')?; + input.skip_whitespace(); + let enc = input.parse_quoted_value()?; + + // XML 1.0 §4.3.3: EncName ::= [A-Za-z] ([A-Za-z0-9._] | '-')* + if !is_valid_encoding_name(&enc) { + return Err(input.fatal(format!("invalid encoding name: '{enc}'"))); + } + + Some(enc) + } else { + None + }; + + // standalone is optional + // If encoding was present, we need fresh whitespace before standalone. + // If encoding was absent, the whitespace consumed when looking for + // encoding already separates version from standalone. + let had_ws2 = input.skip_whitespace() || (encoding.is_none() && had_ws); + let standalone = if input.looking_at(b"standalone") { + if !had_ws2 { + return Err(input.fatal("whitespace required before standalone")); + } + input.expect_str(b"standalone")?; + input.skip_whitespace(); + input.expect_byte(b'=')?; + input.skip_whitespace(); + let val = input.parse_quoted_value()?; + match val.as_str() { + "yes" => Some(true), + "no" => Some(false), + _ => return Err(input.fatal("standalone must be 'yes' or 'no'")), + } + } else { + None + }; + + input.skip_whitespace(); + input.expect_str(b"?>")?; + + Ok(XmlDeclaration { + version, + encoding, + standalone, + }) +} + +/// Validates an XML version number per XML 1.0 §2.8. +/// +/// `VersionNum ::= '1.' [0-9]+` +fn is_valid_version_num(s: &str) -> bool { + if let Some(rest) = s.strip_prefix("1.") { + !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()) + } else { + false + } +} + +/// Validates an encoding name per XML 1.0 §4.3.3. +/// +/// `EncName ::= [A-Za-z] ([A-Za-z0-9._] | '-')*` +fn is_valid_encoding_name(s: &str) -> bool { + let bytes = s.as_bytes(); + if bytes.is_empty() { + return false; + } + if !bytes[0].is_ascii_alphabetic() { + return false; + } + bytes[1..] + .iter() + .all(|&b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-') +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn test_peek_and_advance() { + let mut input = ParserInput::new("abc"); + assert_eq!(input.peek(), Some(b'a')); + assert_eq!(input.peek_at(1), Some(b'b')); + input.advance(1); + assert_eq!(input.peek(), Some(b'b')); + input.advance(2); + assert!(input.at_end()); + } + + #[test] + fn test_line_column_tracking() { + let mut input = ParserInput::new("ab\ncd"); + assert_eq!(input.location().line, 1); + assert_eq!(input.location().column, 1); + input.advance(2); // past "ab" + assert_eq!(input.location().column, 3); + input.advance(1); // past "\n" + assert_eq!(input.location().line, 2); + assert_eq!(input.location().column, 1); + } + + #[test] + fn test_next_char_cr_normalization() { + let mut input = ParserInput::new("a\r\nb"); + assert_eq!(input.next_char().unwrap(), 'a'); + assert_eq!(input.next_char().unwrap(), '\n'); // \r\n → \n + assert_eq!(input.next_char().unwrap(), 'b'); + } + + #[test] + fn test_parse_name() { + let mut input = ParserInput::new("foo:bar "); + let name = input.parse_name().unwrap(); + assert_eq!(name, "foo:bar"); + } + + #[test] + fn test_parse_name_length_limit() { + let long_name = "a".repeat(100); + let mut input = ParserInput::new(&long_name); + input.set_max_name_length(50); + let result = input.parse_name(); + assert!(result.is_err()); + assert!(result.unwrap_err().message.contains("name length")); + } + + #[test] + fn test_parse_reference_builtin() { + let mut input = ParserInput::new("&amp;"); + assert_eq!(input.parse_reference().unwrap(), "&"); + + let mut input = ParserInput::new("&lt;"); + assert_eq!(input.parse_reference().unwrap(), "<"); + + let mut input = ParserInput::new("&gt;"); + assert_eq!(input.parse_reference().unwrap(), ">"); + + let mut input = ParserInput::new("&apos;"); + assert_eq!(input.parse_reference().unwrap(), "'"); + + let mut input = ParserInput::new("&quot;"); + assert_eq!(input.parse_reference().unwrap(), "\""); + } + + #[test] + fn test_parse_reference_char_decimal() { + let mut input = ParserInput::new("&#65;"); + assert_eq!(input.parse_reference().unwrap(), "A"); + } + + #[test] + fn test_parse_reference_char_hex() { + let mut input = ParserInput::new("&#x41;"); + assert_eq!(input.parse_reference().unwrap(), "A"); + } + + #[test] + fn test_parse_reference_unknown_error() { + let mut input = ParserInput::new("&bogus;"); + assert!(input.parse_reference().is_err()); + } + + #[test] + fn test_parse_reference_unknown_recovery() { + let mut input = ParserInput::new("&bogus;"); + input.set_recover(true); + let result = input.parse_reference().unwrap(); + assert_eq!(result, ""); + assert_eq!(input.diagnostics.len(), 1); + } + + #[test] + fn test_entity_expansion_limit() { + let mut input = ParserInput::new("&amp;&amp;&amp;"); + input.set_max_entity_expansions(2); + assert!(input.parse_reference().is_ok()); + assert!(input.parse_reference().is_ok()); + assert!(input.parse_reference().is_err()); + } + + #[test] + fn test_depth_limit() { + let mut input = ParserInput::new(""); + input.set_max_depth(2); + assert!(input.increment_depth().is_ok()); // depth = 1 + assert!(input.increment_depth().is_ok()); // depth = 2 + assert!(input.increment_depth().is_err()); // depth = 3 > 2 + } + + #[test] + fn test_parse_attribute_value() { + let mut input = ParserInput::new("\"hello &amp; world\""); + let value = input.parse_attribute_value().unwrap(); + assert_eq!(value, "hello & world"); + } + + #[test] + fn test_parse_attribute_value_whitespace_normalization() { + let mut input = ParserInput::new("\"a\tb\nc\""); + let value = input.parse_attribute_value().unwrap(); + assert_eq!(value, "a b c"); + } + + #[test] + fn test_parse_quoted_value() { + let mut input = ParserInput::new("'hello'"); + let value = input.parse_quoted_value().unwrap(); + assert_eq!(value, "hello"); + } + + #[test] + fn test_skip_whitespace() { + let mut input = ParserInput::new(" \t\n abc"); + assert!(input.skip_whitespace()); + assert_eq!(input.peek(), Some(b'a')); + } + + #[test] + fn test_looking_at() { + let input = ParserInput::new("<!--comment-->"); + assert!(input.looking_at(b"<!--")); + assert!(!input.looking_at(b"<![CDATA[")); + } + + #[test] + fn test_take_while() { + let mut input = ParserInput::new("12345abc"); + let digits = input.take_while(|b| b.is_ascii_digit()); + assert_eq!(digits, "12345"); + assert_eq!(input.peek(), Some(b'a')); + } + + #[test] + fn test_split_name() { + assert_eq!(split_name("foo:bar"), (Some("foo"), "bar")); + assert_eq!(split_name("bar"), (None, "bar")); + assert_eq!(split_name(":bar"), (Some(""), "bar")); + } + + #[test] + fn test_namespace_resolver() { + let mut ns = NamespaceResolver::new(); + + // xml prefix is pre-bound + assert_eq!(ns.resolve(Some("xml")), Some(XML_NAMESPACE)); + assert_eq!(ns.resolve(None), None); // no default namespace + + ns.push_scope(); + ns.bind(None, "http://default".to_string()); + ns.bind(Some("foo".to_string()), "http://foo".to_string()); + + assert_eq!(ns.resolve(None), Some("http://default")); + assert_eq!(ns.resolve(Some("foo")), Some("http://foo")); + + ns.pop_scope(); + assert_eq!(ns.resolve(None), None); + assert_eq!(ns.resolve(Some("foo")), None); + } + + #[test] + fn test_namespace_undeclare_default() { + let mut ns = NamespaceResolver::new(); + ns.push_scope(); + ns.bind(None, "http://default".to_string()); + assert_eq!(ns.resolve(None), Some("http://default")); + + ns.push_scope(); + ns.bind(None, String::new()); // xmlns="" + assert_eq!(ns.resolve(None), None); + + ns.pop_scope(); + assert_eq!(ns.resolve(None), Some("http://default")); + } + + #[test] + fn test_parse_comment_content() { + let mut input = ParserInput::new("<!-- hello -->"); + let content = parse_comment_content(&mut input).unwrap(); + assert_eq!(content, " hello "); + } + + #[test] + fn test_parse_cdata_content() { + let mut input = ParserInput::new("<![CDATA[some <data>]]>"); + let content = parse_cdata_content(&mut input).unwrap(); + assert_eq!(content, "some <data>"); + } + + #[test] + fn test_parse_pi_content() { + let mut input = ParserInput::new("<?target data?>"); + let (target, data) = parse_pi_content(&mut input).unwrap(); + assert_eq!(target, "target"); + assert_eq!(data.as_deref(), Some("data")); + } + + #[test] + fn test_parse_pi_no_data() { + let mut input = ParserInput::new("<?target?>"); + let (target, data) = parse_pi_content(&mut input).unwrap(); + assert_eq!(target, "target"); + assert_eq!(data, None); + } + + #[test] + fn test_parse_xml_decl() { + let mut input = ParserInput::new("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"); + let decl = parse_xml_decl(&mut input).unwrap(); + assert_eq!(decl.version, "1.0"); + assert_eq!(decl.encoding.as_deref(), Some("UTF-8")); + assert_eq!(decl.standalone, None); + } + + #[test] + fn test_parse_xml_decl_standalone() { + let mut input = + ParserInput::new("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>"); + let decl = parse_xml_decl(&mut input).unwrap(); + assert_eq!(decl.standalone, Some(true)); + } + + #[test] + fn test_is_name_chars() { + assert!(is_name_start_char('a')); + assert!(is_name_start_char('Z')); + assert!(is_name_start_char('_')); + assert!(is_name_start_char(':')); + assert!(!is_name_start_char('0')); + assert!(!is_name_start_char('-')); + + assert!(is_name_char('a')); + assert!(is_name_char('0')); + assert!(is_name_char('-')); + assert!(is_name_char('.')); + assert!(!is_name_char(' ')); + } + + // ===================================================================== + // Security-critical boundary tests + // ===================================================================== + + // -- Depth limit boundary checks -- + + #[test] + fn test_increment_depth_exact_boundary() { + let mut input = ParserInput::new(""); + input.set_max_depth(3); + assert!(input.increment_depth().is_ok()); // depth = 1 + assert!(input.increment_depth().is_ok()); // depth = 2 + assert!(input.increment_depth().is_ok()); // depth = 3 (== max) + assert!(input.increment_depth().is_err()); // depth = 4 > 3 + } + + #[test] + fn test_increment_depth_max_depth_one() { + // Edge case: max_depth = 1 means only one level allowed + let mut input = ParserInput::new(""); + input.set_max_depth(1); + assert!(input.increment_depth().is_ok()); // depth = 1 + let err = input.increment_depth().unwrap_err(); + assert!(err.message.contains("maximum nesting depth exceeded")); + } + + #[test] + fn test_increment_depth_max_depth_zero() { + // max_depth = 0 means no nesting allowed at all + let mut input = ParserInput::new(""); + input.set_max_depth(0); + let err = input.increment_depth().unwrap_err(); + assert!(err.message.contains("maximum nesting depth exceeded")); + } + + #[test] + fn test_decrement_depth_saturates_at_zero() { + let mut input = ParserInput::new(""); + // Decrementing from 0 should not underflow + input.decrement_depth(); + assert_eq!(input.depth(), 0); + // Increment then decrement twice — should saturate + input.increment_depth().unwrap(); + assert_eq!(input.depth(), 1); + input.decrement_depth(); + assert_eq!(input.depth(), 0); + input.decrement_depth(); + assert_eq!(input.depth(), 0); + } + + #[test] + fn test_depth_resets_after_decrement_allows_reentry() { + // Verify that after popping back under the limit, new pushes succeed + let mut input = ParserInput::new(""); + input.set_max_depth(2); + assert!(input.increment_depth().is_ok()); // depth = 1 + assert!(input.increment_depth().is_ok()); // depth = 2 + input.decrement_depth(); // depth = 1 + assert!(input.increment_depth().is_ok()); // depth = 2 again + assert!(input.increment_depth().is_err()); // depth = 3 > 2 + } + + // -- Entity expansion limit boundary checks -- + + #[test] + fn test_entity_expansion_limit_exact_boundary() { + // max_entity_expansions = 3 means exactly 3 are allowed + let mut input = ParserInput::new("&amp;&amp;&amp;&amp;"); + input.set_max_entity_expansions(3); + assert!(input.parse_reference().is_ok()); // expansion 1 + assert!(input.parse_reference().is_ok()); // expansion 2 + assert!(input.parse_reference().is_ok()); // expansion 3 + let err = input.parse_reference().unwrap_err(); + assert!(err.message.contains("entity expansion limit exceeded")); + } + + #[test] + fn test_entity_expansion_limit_zero() { + // max_entity_expansions = 0 means no expansions allowed + let mut input = ParserInput::new("&amp;"); + input.set_max_entity_expansions(0); + let err = input.parse_reference().unwrap_err(); + assert!(err.message.contains("entity expansion limit exceeded")); + } + + #[test] + fn test_entity_expansion_limit_one() { + let mut input = ParserInput::new("&amp;&lt;"); + input.set_max_entity_expansions(1); + assert!(input.parse_reference().is_ok()); + let err = input.parse_reference().unwrap_err(); + assert!(err.message.contains("entity expansion limit exceeded")); + } + + #[test] + fn test_entity_expansion_counter_includes_char_refs() { + // Character references also increment the entity expansion counter + let mut input = ParserInput::new("&#65;&#66;&#67;"); + input.set_max_entity_expansions(2); + assert!(input.parse_reference().is_ok()); // &#65; → A + assert!(input.parse_reference().is_ok()); // &#66; → B + let err = input.parse_reference().unwrap_err(); + assert!(err.message.contains("entity expansion limit exceeded")); + } + + #[test] + fn test_entity_expansion_limit_via_parse_str() { + use crate::parser::{parse_str_with_options, ParseOptions}; + // Test through the high-level API: document with many char refs + let refs: String = (0..50).map(|_| "&#65;").collect(); + let xml = format!("<r>{refs}</r>"); + let opts = ParseOptions::default().max_entity_expansions(10); + let result = parse_str_with_options(&xml, &opts); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .message + .contains("entity expansion limit")); + } + + // -- Entity expansion: DTD internal entity recursion -- + + #[test] + fn test_entity_expansion_dtd_internal_entity() { + use crate::parser::{parse_str_with_options, ParseOptions}; + let xml = r#"<!DOCTYPE r [ +<!ENTITY greet "Hello"> +]> +<r>&greet;</r>"#; + let doc = parse_str_with_options(xml, &ParseOptions::default()).unwrap(); + let root = doc.root_element().unwrap(); + assert_eq!(doc.text_content(root), "Hello"); + } + + #[test] + fn test_entity_expansion_nested_dtd_entities() { + use crate::parser::{parse_str_with_options, ParseOptions}; + // Entity "b" references entity "a". The nested reference is parsed + // as content (XML 1.0 §4.4), so the entity expansion is visible + // through text_content. + let xml = r#"<!DOCTYPE r [ +<!ENTITY a "world"> +<!ENTITY b "hello &a;"> +]> +<r>&b;</r>"#; + let doc = parse_str_with_options(xml, &ParseOptions::default()).unwrap(); + let root = doc.root_element().unwrap(); + assert_eq!(doc.text_content(root), "hello world"); + } + + #[test] + fn test_entity_expansion_limit_nested_dtd_entities_in_attributes() { + use crate::parser::{parse_str_with_options, ParseOptions}; + // In attribute values, entity references ARE fully expanded through + // parse_reference_into + expand_entity_text. Each expansion counts + // against the limit. Chain: c -> 3*b -> 9*a = 13 total expansions. + let xml = r#"<!DOCTYPE r [ +<!ENTITY a "x"> +<!ENTITY b "&a;&a;&a;"> +<!ENTITY c "&b;&b;&b;"> +]> +<r v="&c;"/>"#; + let opts = ParseOptions::default().max_entity_expansions(5); + let result = parse_str_with_options(xml, &opts); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .message + .contains("entity expansion limit")); + } + + // -- Billion laughs style attack (exponential entity expansion) -- + + #[test] + fn test_billion_laughs_entity_bomb_in_attribute() { + use crate::parser::{parse_str_with_options, ParseOptions}; + // Classic billion laughs pattern in an attribute value where + // entities ARE fully expanded. Each entity references the + // previous one multiple times, causing exponential expansion. + let xml = r#"<!DOCTYPE r [ +<!ENTITY lol "lol"> +<!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;"> +<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;"> +<!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;"> +]> +<r v="&lol4;"/>"#; + // lol4 -> 10 * lol3 -> 100 * lol2 -> 1000 * lol = 1111 expansions + let opts = ParseOptions::default().max_entity_expansions(100); + let result = parse_str_with_options(xml, &opts); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.message.contains("entity expansion limit"), + "billion laughs should be caught by expansion limit, got: {}", + err.message + ); + } + + #[test] + fn test_billion_laughs_in_text_content_with_markup() { + use crate::parser::{parse_str_with_options, ParseOptions}; + // When entity replacement text contains '<', the parser must expand + // it (to validate the markup), which triggers entity expansion + // counting. This tests the billion laughs pattern for text content + // entities that contain markup. + let xml = r#"<!DOCTYPE r [ +<!ENTITY lol "lol"> +<!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;"> +<!ENTITY lol3 "<i>&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;</i>"> +]> +<r>&lol3;</r>"#; + let opts = ParseOptions::default().max_entity_expansions(50); + let result = parse_str_with_options(xml, &opts); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.message.contains("entity expansion limit"), + "billion laughs with markup should be caught, got: {}", + err.message + ); + } + + // -- XXE prevention: external entity references without resolver -- + + #[test] + fn test_xxe_external_entity_rejected_by_default() { + use crate::parser::{parse_str_with_options, ParseOptions}; + let xml = r#"<!DOCTYPE r [ +<!ENTITY xxe SYSTEM "file:///etc/passwd"> +]> +<r>&xxe;</r>"#; + let result = parse_str_with_options(xml, &ParseOptions::default()); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.message.contains("external entity"), + "XXE should be rejected by default, got: {}", + err.message + ); + } + + #[test] + fn test_xxe_external_entity_with_public_id_rejected() { + use crate::parser::{parse_str_with_options, ParseOptions}; + let xml = r#"<!DOCTYPE r [ +<!ENTITY xxe PUBLIC "-//Evil//EN" "http://evil.com/payload"> +]> +<r>&xxe;</r>"#; + let result = parse_str_with_options(xml, &ParseOptions::default()); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.message.contains("external entity"), + "XXE with PUBLIC id should be rejected, got: {}", + err.message + ); + } + + #[test] + fn test_xxe_external_entity_in_attribute_rejected() { + use crate::parser::{parse_str_with_options, ParseOptions}; + let xml = r#"<!DOCTYPE r [ +<!ENTITY xxe SYSTEM "file:///etc/shadow"> +]> +<r a="&xxe;"/>"#; + let result = parse_str_with_options(xml, &ParseOptions::default()); + assert!(result.is_err(), "XXE in attribute should be rejected"); + } + + #[test] + fn test_xxe_multiple_external_entities_all_rejected() { + use crate::parser::{parse_str_with_options, ParseOptions}; + // Even if one entity is internal, the external one should fail + let xml = r#"<!DOCTYPE r [ +<!ENTITY safe "ok"> +<!ENTITY evil SYSTEM "file:///etc/passwd"> +]> +<r>&safe;&evil;</r>"#; + let result = parse_str_with_options(xml, &ParseOptions::default()); + assert!(result.is_err()); + } + + // -- Character reference edge cases -- + + #[test] + fn test_char_ref_null_character_rejected() { + // &#0; is not a valid XML character (XML 1.0 §2.2) + let mut input = ParserInput::new("&#0;"); + let err = input.parse_reference().unwrap_err(); + assert!( + err.message.contains("valid XML character"), + "null char ref should be rejected as invalid XML char, got: {}", + err.message + ); + } + + #[test] + fn test_char_ref_null_hex_rejected() { + let mut input = ParserInput::new("&#x0;"); + let err = input.parse_reference().unwrap_err(); + assert!( + err.message.contains("valid XML character"), + "&#x0; should be rejected, got: {}", + err.message + ); + } + + #[test] + fn test_char_ref_control_chars_rejected() { + // Control characters 0x01-0x08, 0x0B, 0x0C, 0x0E-0x1F are invalid + for codepoint in [1u32, 2, 7, 8, 0x0B, 0x0C, 0x0E, 0x1F] { + let ref_str = format!("&#x{codepoint:X};"); + let mut input = ParserInput::new(&ref_str); + let result = input.parse_reference(); + assert!( + result.is_err(), + "&#x{codepoint:X}; should be rejected as invalid XML character" + ); + } + } + + #[test] + fn test_char_ref_allowed_control_chars() { + // Tab (0x09), LF (0x0A), CR (0x0D) ARE valid XML characters + let mut input = ParserInput::new("&#x9;"); + assert_eq!(input.parse_reference().unwrap(), "\t"); + + let mut input = ParserInput::new("&#xA;"); + assert_eq!(input.parse_reference().unwrap(), "\n"); + + let mut input = ParserInput::new("&#xD;"); + assert_eq!(input.parse_reference().unwrap(), "\r"); + } + + #[test] + fn test_char_ref_surrogate_codepoints_rejected() { + // U+D800 through U+DFFF are surrogates, not valid Unicode scalar values. + // char::from_u32 returns None for these. + let mut input = ParserInput::new("&#xD800;"); + let err = input.parse_reference().unwrap_err(); + assert!( + err.message.contains("invalid character reference"), + "surrogate &#xD800; should be rejected, got: {}", + err.message + ); + + let mut input = ParserInput::new("&#xDFFF;"); + let err = input.parse_reference().unwrap_err(); + assert!( + err.message.contains("invalid character reference"), + "surrogate &#xDFFF; should be rejected, got: {}", + err.message + ); + } + + #[test] + fn test_char_ref_fffe_and_ffff_rejected() { + // U+FFFE and U+FFFF are not valid XML characters per §2.2 + let mut input = ParserInput::new("&#xFFFE;"); + let err = input.parse_reference().unwrap_err(); + assert!( + err.message.contains("valid XML character"), + "&#xFFFE; should be rejected, got: {}", + err.message + ); + + let mut input = ParserInput::new("&#xFFFF;"); + let err = input.parse_reference().unwrap_err(); + assert!( + err.message.contains("valid XML character"), + "&#xFFFF; should be rejected, got: {}", + err.message + ); + } + + #[test] + fn test_char_ref_max_valid_codepoint() { + // U+10FFFF is the highest valid Unicode scalar value and a valid + // XML character per §2.2 + let mut input = ParserInput::new("&#x10FFFF;"); + let result = input.parse_reference().unwrap(); + assert_eq!(result, "\u{10FFFF}"); + } + + #[test] + fn test_char_ref_beyond_unicode_range() { + // U+110000 is beyond the Unicode range — char::from_u32 returns None + let mut input = ParserInput::new("&#x110000;"); + let err = input.parse_reference().unwrap_err(); + assert!( + err.message.contains("invalid character reference"), + "codepoint beyond Unicode range should be rejected, got: {}", + err.message + ); + } + + #[test] + fn test_char_ref_very_large_decimal_rejected() { + // A huge decimal value that overflows u32 + let mut input = ParserInput::new("&#99999999999;"); + let err = input.parse_reference().unwrap_err(); + assert!( + err.message.contains("invalid decimal character reference"), + "overflowing decimal char ref should be rejected, got: {}", + err.message + ); + } + + #[test] + fn test_char_ref_very_large_hex_rejected() { + // A huge hex value that overflows u32 + let mut input = ParserInput::new("&#xFFFFFFFFFF;"); + let err = input.parse_reference().unwrap_err(); + assert!( + err.message.contains("invalid hex character reference"), + "overflowing hex char ref should be rejected, got: {}", + err.message + ); + } + + #[test] + fn test_char_ref_empty_decimal_rejected() { + let mut input = ParserInput::new("&#;"); + let err = input.parse_reference().unwrap_err(); + assert!( + err.message.contains("empty decimal character reference"), + "empty decimal ref should be rejected, got: {}", + err.message + ); + } + + #[test] + fn test_char_ref_empty_hex_rejected() { + let mut input = ParserInput::new("&#x;"); + let err = input.parse_reference().unwrap_err(); + assert!( + err.message.contains("empty hex character reference"), + "empty hex ref should be rejected, got: {}", + err.message + ); + } + + #[test] + fn test_char_ref_valid_bmp_characters() { + // Space (0x20), Latin A (0x41), CJK character + let mut input = ParserInput::new("&#x20;"); + assert_eq!(input.parse_reference().unwrap(), " "); + + let mut input = ParserInput::new("&#x41;"); + assert_eq!(input.parse_reference().unwrap(), "A"); + + let mut input = ParserInput::new("&#x4E2D;"); // CJK '中' + assert_eq!(input.parse_reference().unwrap(), "\u{4E2D}"); + } + + #[test] + fn test_char_ref_supplementary_plane() { + // Musical symbol G clef: U+1D11E + let mut input = ParserInput::new("&#x1D11E;"); + assert_eq!(input.parse_reference().unwrap(), "\u{1D11E}"); + } + + // -- Name length limit edge cases -- + + #[test] + fn test_parse_name_at_exact_length_limit() { + let name = "a".repeat(50); + let input_str = format!("{name} "); + let mut input = ParserInput::new(&input_str); + input.set_max_name_length(50); + let result = input.parse_name().unwrap(); + assert_eq!(result.len(), 50); + } + + #[test] + fn test_parse_name_one_over_length_limit() { + let name = "a".repeat(51); + let input_str = format!("{name} "); + let mut input = ParserInput::new(&input_str); + input.set_max_name_length(50); + let result = input.parse_name(); + assert!(result.is_err()); + assert!(result.unwrap_err().message.contains("name length")); + } + + #[test] + fn test_parse_name_length_limit_one() { + // Single-character names should work with limit = 1 + let mut input = ParserInput::new("a "); + input.set_max_name_length(1); + assert_eq!(input.parse_name().unwrap(), "a"); + + // Two-character name should fail with limit = 1 + let mut input = ParserInput::new("ab "); + input.set_max_name_length(1); + assert!(input.parse_name().is_err()); + } + + #[test] + fn test_parse_name_unicode_length_counted_in_bytes() { + // Unicode names — the limit is in bytes, not characters. + // \u{C0} is 'À' which is 2 bytes in UTF-8. + let name = "\u{C0}\u{C0}\u{C0}"; // 6 bytes + let input_str = format!("{name} "); + let mut input = ParserInput::new(&input_str); + input.set_max_name_length(5); + let result = input.parse_name(); + assert!( + result.is_err(), + "6-byte unicode name should exceed 5-byte limit" + ); + + let mut input = ParserInput::new(&input_str); + input.set_max_name_length(6); + assert!( + input.parse_name().is_ok(), + "6-byte unicode name should fit 6-byte limit" + ); + } + + #[test] + fn test_parse_name_eq_length_limit() { + let name = "a".repeat(51); + let input_str = format!("{name} "); + let mut input = ParserInput::new(&input_str); + input.set_max_name_length(50); + let result = input.parse_name_eq("something"); + assert!(result.is_err()); + assert!(result.unwrap_err().message.contains("name length")); + } + + #[test] + fn test_parse_name_eq_parts_length_limit() { + let name = "a".repeat(51); + let input_str = format!("{name} "); + let mut input = ParserInput::new(&input_str); + input.set_max_name_length(50); + let result = input.parse_name_eq_parts(None, "something"); + assert!(result.is_err()); + assert!(result.unwrap_err().message.contains("name length")); + } + + // -- Comment boundary edge cases -- + + #[test] + fn test_comment_double_dash_rejected() { + // `--` inside a comment is not allowed per XML 1.0 §2.5 + let mut input = ParserInput::new("<!-- bad -- comment -->"); + let result = parse_comment_content(&mut input); + assert!(result.is_err()); + assert!( + result.unwrap_err().message.contains("'--' not allowed"), + "double dash inside comment should be rejected" + ); + } + + #[test] + fn test_comment_double_dash_recovery() { + let mut input = ParserInput::new("<!-- bad -- comment -->"); + input.set_recover(true); + let content = parse_comment_content(&mut input).unwrap(); + assert!(content.contains("--")); + assert!(!input.diagnostics.is_empty()); + } + + #[test] + fn test_comment_unterminated() { + let mut input = ParserInput::new("<!-- no end"); + let result = parse_comment_content(&mut input); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .message + .contains("unexpected end of input in comment")); + } + + #[test] + fn test_comment_empty() { + let mut input = ParserInput::new("<!---->"); + let content = parse_comment_content(&mut input).unwrap(); + assert_eq!(content, ""); + } + + #[test] + fn test_comment_single_dash_allowed() { + // A single dash followed by a non-dash is allowed in comments + let mut input = ParserInput::new("<!-- a - b -->"); + let content = parse_comment_content(&mut input).unwrap(); + assert_eq!(content, " a - b "); + } + + #[test] + fn test_comment_ending_with_triple_dash_rejected() { + // `<!--- --->` contains `--` followed by `->`, which means + // the `--` appears inside the comment (the comment ends at `-->`) + let mut input = ParserInput::new("<!----->"); + let result = parse_comment_content(&mut input); + // The scanner finds `--` at position 0 inside the comment content, + // but then sees `-->` — this is actually `--` + `>` which is "--->" + // meaning the content is "-" and there is a bare "--" before the `>`. + assert!( + result.is_err() || { + // In recovery mode it might succeed with a diagnostic + false + } + ); + } + + // -- CDATA boundary edge cases -- + + #[test] + fn test_cdata_unterminated() { + let mut input = ParserInput::new("<![CDATA[no end"); + let result = parse_cdata_content(&mut input); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .message + .contains("unexpected end of input in CDATA")); + } + + #[test] + fn test_cdata_empty() { + let mut input = ParserInput::new("<![CDATA[]]>"); + let content = parse_cdata_content(&mut input).unwrap(); + assert_eq!(content, ""); + } + + #[test] + fn test_cdata_with_angle_brackets() { + // CDATA sections can contain < and > freely + let mut input = ParserInput::new("<![CDATA[<div>hello</div>]]>"); + let content = parse_cdata_content(&mut input).unwrap(); + assert_eq!(content, "<div>hello</div>"); + } + + #[test] + fn test_cdata_with_double_bracket_not_terminator() { + // `]]` without `>` should not end the CDATA section + let mut input = ParserInput::new("<![CDATA[a]]b]]>"); + let content = parse_cdata_content(&mut input).unwrap(); + assert_eq!(content, "a]]b"); + } + + #[test] + fn test_cdata_with_ampersand() { + // Entity references are NOT expanded in CDATA sections + let mut input = ParserInput::new("<![CDATA[&amp; &lt;]]>"); + let content = parse_cdata_content(&mut input).unwrap(); + assert_eq!(content, "&amp; &lt;"); + } + + // -- Processing instruction boundary edge cases -- + + #[test] + fn test_pi_target_xml_reserved() { + let mut input = ParserInput::new("<?xml data?>"); + let result = parse_pi_content(&mut input); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .message + .contains("PI target 'xml' is reserved")); + } + + #[test] + fn test_pi_target_xml_case_insensitive() { + // "XML", "Xml", etc. should all be reserved + for target in ["XML", "Xml", "xMl", "xmL"] { + let pi = format!("<?{target} data?>"); + let mut input = ParserInput::new(&pi); + let result = parse_pi_content(&mut input); + assert!(result.is_err(), "PI target '{target}' should be reserved"); + } + } + + #[test] + fn test_pi_target_with_colon_rejected() { + let mut input = ParserInput::new("<?ns:target data?>"); + let result = parse_pi_content(&mut input); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .message + .contains("must not contain a colon")); + } + + #[test] + fn test_pi_unterminated() { + let mut input = ParserInput::new("<?target no end"); + let result = parse_pi_content(&mut input); + assert!(result.is_err()); + } + + #[test] + fn test_pi_empty_data_after_whitespace() { + let mut input = ParserInput::new("<?target ?>"); + let (target, data) = parse_pi_content(&mut input).unwrap(); + assert_eq!(target, "target"); + assert_eq!(data, None); // whitespace only, no real data + } + + // -- Attribute value security edge cases -- + + #[test] + fn test_attribute_value_less_than_rejected() { + // `<` is not allowed in attribute values per XML 1.0 §3.1 + let mut input = ParserInput::new("\"abc<def\""); + let result = input.parse_attribute_value(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .message + .contains("'<' not allowed in attribute values")); + } + + #[test] + fn test_attribute_value_unterminated() { + let mut input = ParserInput::new("\"no closing quote"); + let result = input.parse_attribute_value(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .message + .contains("unexpected end of input")); + } + + #[test] + fn test_attribute_value_not_quoted() { + let mut input = ParserInput::new("unquoted"); + let result = input.parse_attribute_value(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .message + .contains("attribute value must be quoted")); + } + + #[test] + fn test_attribute_value_single_quotes() { + let mut input = ParserInput::new("'hello'"); + let value = input.parse_attribute_value().unwrap(); + assert_eq!(value, "hello"); + } + + #[test] + fn test_attribute_value_entity_with_less_than_rejected() { + use crate::parser::{parse_str_with_options, ParseOptions}; + // Entity whose replacement text contains '<' is rejected + // per WFC: No < in Attribute Values + let xml = r#"<!DOCTYPE r [ +<!ENTITY bad "a&lt;b"> +]> +<r a="&bad;"/>"#; + // Note: &lt; in the entity value is expanded to <, which is then + // found in the attribute value. Whether this triggers the WFC check + // depends on the implementation's handling of nested expansion. + // This test verifies the parser has SOME handling for this case. + let result = parse_str_with_options(xml, &ParseOptions::default()); + // The entity "bad" contains "&lt;" which expands to "<". + // This "<" in attribute value should be caught. + assert!(result.is_err()); + } + + // -- Invalid XML character detection -- + + #[test] + fn test_is_xml_char_boundary_values() { + // Valid boundary values + assert!(is_xml_char('\t')); // U+0009 + assert!(is_xml_char('\n')); // U+000A + assert!(is_xml_char('\r')); // U+000D + assert!(is_xml_char(' ')); // U+0020 + assert!(is_xml_char('\u{D7FF}')); + assert!(is_xml_char('\u{E000}')); + assert!(is_xml_char('\u{FFFD}')); + assert!(is_xml_char('\u{10000}')); + assert!(is_xml_char('\u{10FFFF}')); + + // Invalid boundary values + assert!(!is_xml_char('\0')); // U+0000 + assert!(!is_xml_char('\u{0001}')); // U+0001 + assert!(!is_xml_char('\u{0008}')); // U+0008 + assert!(!is_xml_char('\u{000B}')); // U+000B + assert!(!is_xml_char('\u{000C}')); // U+000C + assert!(!is_xml_char('\u{000E}')); // U+000E + assert!(!is_xml_char('\u{001F}')); // U+001F + assert!(!is_xml_char('\u{FFFE}')); // U+FFFE + assert!(!is_xml_char('\u{FFFF}')); // U+FFFF + } + + #[test] + fn test_next_char_rejects_control_characters() { + // U+0001 (SOH) is an invalid XML character + let input_bytes = "\x01"; + let mut input = ParserInput::new(input_bytes); + let result = input.next_char(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .message + .contains("invalid XML character")); + } + + #[test] + fn test_next_char_control_char_recovery() { + let input_bytes = "\x01X"; + let mut input = ParserInput::new(input_bytes); + input.set_recover(true); + // In recovery mode, control chars produce diagnostics but parsing continues + let ch = input.next_char().unwrap(); + assert_eq!(ch, '\x01'); + assert!(!input.diagnostics.is_empty()); + // Next character should work fine + assert_eq!(input.next_char().unwrap(), 'X'); + } + + // -- Scan boundary edge cases -- + + #[test] + fn test_scan_char_data_cdata_end_marker() { + // `]]>` in character data is not allowed — the scanner should stop before it + let input = ParserInput::new("text]]>more"); + let len = input.scan_char_data(); + assert_eq!(len, 4); // stops before `]]>` + } + + #[test] + fn test_scan_char_data_empty() { + let input = ParserInput::new("<"); + assert_eq!(input.scan_char_data(), 0); + } + + #[test] + fn test_scan_char_data_stops_at_ampersand() { + let input = ParserInput::new("text&ref;"); + assert_eq!(input.scan_char_data(), 4); + } + + #[test] + fn test_scan_char_data_stops_at_less_than() { + let input = ParserInput::new("text<elem"); + assert_eq!(input.scan_char_data(), 4); + } + + #[test] + fn test_scan_for_2byte_terminator_at_end() { + // Input too short for any 2-byte terminator + let input = ParserInput::new("x"); + assert_eq!(input.scan_for_2byte_terminator(b'-', b'-'), None); + } + + #[test] + fn test_scan_for_2byte_terminator_exact_2_bytes() { + let input = ParserInput::new("--"); + assert_eq!(input.scan_for_2byte_terminator(b'-', b'-'), Some(0)); + } + + #[test] + fn test_scan_for_3byte_terminator_at_end() { + let input = ParserInput::new("]]"); + assert_eq!(input.scan_for_3byte_terminator(b']', b']', b'>'), None); + } + + #[test] + fn test_scan_for_3byte_terminator_exact_3_bytes() { + let input = ParserInput::new("]]>"); + assert_eq!(input.scan_for_3byte_terminator(b']', b']', b'>'), Some(0)); + } + + // -- Namespace resolution edge cases -- + + #[test] + fn test_namespace_resolver_nested_override() { + let mut ns = NamespaceResolver::new(); + ns.push_scope(); + ns.bind(Some("p".to_string()), "http://outer".to_string()); + assert_eq!(ns.resolve(Some("p")), Some("http://outer")); + + // Inner scope overrides the same prefix + ns.push_scope(); + ns.bind(Some("p".to_string()), "http://inner".to_string()); + assert_eq!(ns.resolve(Some("p")), Some("http://inner")); + + // After popping, outer binding is restored + ns.pop_scope(); + assert_eq!(ns.resolve(Some("p")), Some("http://outer")); + + ns.pop_scope(); + assert_eq!(ns.resolve(Some("p")), None); + } + + #[test] + fn test_namespace_resolver_default_ns_override_and_restore() { + let mut ns = NamespaceResolver::new(); + ns.push_scope(); + ns.bind(None, "http://a".to_string()); + ns.push_scope(); + ns.bind(None, "http://b".to_string()); + assert_eq!(ns.resolve(None), Some("http://b")); + ns.pop_scope(); + assert_eq!(ns.resolve(None), Some("http://a")); + ns.pop_scope(); + assert_eq!(ns.resolve(None), None); + } + + #[test] + fn test_namespace_resolver_undeclare_default_then_redeclare() { + let mut ns = NamespaceResolver::new(); + ns.push_scope(); + ns.bind(None, "http://ns".to_string()); + ns.push_scope(); + ns.bind(None, String::new()); // undeclare + assert_eq!(ns.resolve(None), None); + ns.push_scope(); + ns.bind(None, "http://new".to_string()); // re-declare + assert_eq!(ns.resolve(None), Some("http://new")); + ns.pop_scope(); + assert_eq!(ns.resolve(None), None); // back to undeclared + ns.pop_scope(); + assert_eq!(ns.resolve(None), Some("http://ns")); // original + ns.pop_scope(); + assert_eq!(ns.resolve(None), None); + } + + #[test] + fn test_namespace_resolver_xml_prefix_always_bound() { + let ns = NamespaceResolver::new(); + assert_eq!(ns.resolve(Some("xml")), Some(XML_NAMESPACE)); + } + + #[test] + fn test_namespace_resolver_unbound_prefix() { + let ns = NamespaceResolver::new(); + assert_eq!(ns.resolve(Some("foo")), None); + assert_eq!(ns.resolve(Some("xmlns")), None); + } + + #[test] + fn test_namespace_resolver_many_scopes() { + // Stress test: push and pop many scopes with bindings + let mut ns = NamespaceResolver::new(); + for i in 0..100 { + ns.push_scope(); + ns.bind(Some("p".to_string()), format!("http://ns/{i}")); + } + assert_eq!(ns.resolve(Some("p")), Some("http://ns/99")); + for i in (0..100).rev() { + ns.pop_scope(); + if i > 0 { + let expected = format!("http://ns/{}", i - 1); + assert_eq!(ns.resolve(Some("p")), Some(expected.as_str())); + } + } + assert_eq!(ns.resolve(Some("p")), None); + } + + // -- QName validation edge cases -- + + #[test] + fn test_validate_qname_valid() { + assert_eq!(validate_qname("foo"), None); + assert_eq!(validate_qname("ns:local"), None); + assert_eq!(validate_qname("a"), None); + } + + #[test] + fn test_validate_qname_multiple_colons() { + let result = validate_qname("a:b:c"); + assert!(result.is_some()); + assert!(result.unwrap().contains("multiple colons")); + } + + #[test] + fn test_validate_qname_empty_prefix() { + let result = validate_qname(":local"); + assert!(result.is_some()); + assert!(result.unwrap().contains("empty prefix or local part")); + } + + #[test] + fn test_validate_qname_empty_local() { + let result = validate_qname("prefix:"); + assert!(result.is_some()); + assert!(result.unwrap().contains("empty prefix or local part")); + } + + // -- split_owned_name edge cases -- + + #[test] + fn test_split_owned_name_with_prefix() { + let (prefix, local) = split_owned_name("ns:elem".to_string()); + assert_eq!(prefix.as_deref(), Some("ns")); + assert_eq!(local, "elem"); + } + + #[test] + fn test_split_owned_name_no_prefix() { + let (prefix, local) = split_owned_name("elem".to_string()); + assert_eq!(prefix, None); + assert_eq!(local, "elem"); + } + + // -- pubid validation edge cases -- + + #[test] + fn test_validate_pubid_valid() { + assert_eq!(validate_pubid("-//W3C//DTD XML 1.0//EN"), None); + } + + #[test] + fn test_validate_pubid_invalid_char() { + let result = validate_pubid("bad\x01char"); + assert!(result.is_some()); + assert!(result.unwrap().contains("invalid character")); + } + + // -- XML declaration edge cases -- + + #[test] + fn test_xml_decl_invalid_version() { + let mut input = ParserInput::new("<?xml version=\"2.0\"?>"); + let result = parse_xml_decl(&mut input); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .message + .contains("invalid version number")); + } + + #[test] + fn test_xml_decl_invalid_encoding() { + let mut input = ParserInput::new("<?xml version=\"1.0\" encoding=\"123bad\"?>"); + let result = parse_xml_decl(&mut input); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .message + .contains("invalid encoding name")); + } + + #[test] + fn test_xml_decl_standalone_invalid() { + let mut input = ParserInput::new("<?xml version=\"1.0\" standalone=\"maybe\"?>"); + let result = parse_xml_decl(&mut input); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .message + .contains("standalone must be 'yes' or 'no'")); + } + + #[test] + fn test_xml_decl_standalone_no() { + let mut input = ParserInput::new("<?xml version=\"1.0\" standalone=\"no\"?>"); + let decl = parse_xml_decl(&mut input).unwrap(); + assert_eq!(decl.standalone, Some(false)); + } + + // -- Position save/restore edge cases -- + + #[test] + fn test_save_restore_position() { + let mut input = ParserInput::new("abcdef"); + input.advance(3); + assert_eq!(input.peek(), Some(b'd')); + let saved = input.save_position(); + input.advance(2); + assert_eq!(input.peek(), Some(b'f')); + input.restore_position(saved); + assert_eq!(input.peek(), Some(b'd')); + assert_eq!(input.location().column, 4); // restored column + } + + // -- Depth limit via full parser (integration-level, but testing + // the boundary precisely through the public API) -- + + #[test] + fn test_depth_limit_via_parse_str_with_options() { + use crate::parser::{parse_str_with_options, ParseOptions}; + // 3 levels with limit 3 should succeed + let xml = "<a><b><c/></b></a>"; + let opts = ParseOptions::default().max_depth(3); + assert!(parse_str_with_options(xml, &opts).is_ok()); + + // 4 levels with limit 3 should fail + let xml = "<a><b><c><d/></c></b></a>"; + let result = parse_str_with_options(xml, &opts); + assert!(result.is_err()); + assert!(result.unwrap_err().message.contains("depth")); + } + + // -- Builtin entity matching edge cases -- + + #[test] + fn test_match_builtin_entity_all() { + assert_eq!(match_builtin_entity(b"amp;"), Some(("&", 4))); + assert_eq!(match_builtin_entity(b"lt;"), Some(("<", 3))); + assert_eq!(match_builtin_entity(b"gt;"), Some((">", 3))); + assert_eq!(match_builtin_entity(b"apos;"), Some(("'", 5))); + assert_eq!(match_builtin_entity(b"quot;"), Some(("\"", 5))); + } + + #[test] + fn test_match_builtin_entity_partial_no_match() { + // Partial matches should return None + assert_eq!(match_builtin_entity(b"am"), None); + assert_eq!(match_builtin_entity(b"l"), None); + assert_eq!(match_builtin_entity(b"apo"), None); + assert_eq!(match_builtin_entity(b"quo"), None); + } + + #[test] + fn test_match_builtin_entity_unknown() { + assert_eq!(match_builtin_entity(b"foo;"), None); + assert_eq!(match_builtin_entity(b""), None); + assert_eq!(match_builtin_entity(b"x"), None); + } + + // -- expand_entity_text security: nested entity expansion limit -- + + #[test] + fn test_expand_entity_text_counts_against_limit() { + // Set up a ParserInput with entity_map containing nested references + let mut input = ParserInput::new(""); + input + .entity_map + .insert("a".to_string(), "hello".to_string()); + input + .entity_map + .insert("b".to_string(), "&a; &a;".to_string()); + input.set_max_entity_expansions(2); + + // Expanding "b" should expand &a; twice, hitting the limit + let result = input.expand_entity_text("&b;"); + // "b" expands to "&a; &a;", then each &a; expansion counts. + // Entity count: 1 (for b) + 1 (first a) + 1 (second a) = 3 > 2 + // But note: expand_entity_text doesn't count the outer reference + // itself, only the inner ones. Let's verify the behavior: + // The first &a; increments to 1, second &a; to 2, and on the + // next call (which would be the &b; reference) it would hit the limit. + // Actually, expand_entity_text handles the inner references only. + // The outer reference (&b;) was already counted by the caller. + // With limit=2, the inner &a; references (2 of them) exactly hit the limit. + assert!( + result.is_ok() || result.is_err(), + "expansion should either succeed at limit or fail over limit" + ); + } + + #[test] + fn test_expand_entity_text_no_references() { + let mut input = ParserInput::new(""); + let result = input.expand_entity_text("plain text").unwrap(); + assert_eq!(result, "plain text"); + } + + #[test] + fn test_expand_entity_text_builtin_entities() { + let mut input = ParserInput::new(""); + let result = input.expand_entity_text("a &amp; b &lt; c").unwrap(); + assert_eq!(result, "a & b < c"); + } + + #[test] + fn test_expand_entity_text_char_refs() { + let mut input = ParserInput::new(""); + let result = input.expand_entity_text("&#65; &#x42;").unwrap(); + assert_eq!(result, "A B"); + } + + #[test] + fn test_expand_entity_text_unknown_entity_strict() { + let mut input = ParserInput::new(""); + let result = input.expand_entity_text("&unknown;"); + assert!(result.is_err()); + } + + #[test] + fn test_expand_entity_text_cdata_not_expanded() { + let mut input = ParserInput::new(""); + let result = input + .expand_entity_text("<![CDATA[&amp; not expanded]]>") + .unwrap(); + assert_eq!(result, "<![CDATA[&amp; not expanded]]>"); + } + + // -- find_invalid_xml_char and may_contain_invalid_xml_chars -- + + #[test] + fn test_find_invalid_xml_char_clean() { + assert_eq!(find_invalid_xml_char("hello world"), None); + assert_eq!(find_invalid_xml_char("tab\there"), None); + assert_eq!(find_invalid_xml_char("newline\nhere"), None); + } + + #[test] + fn test_find_invalid_xml_char_with_null() { + assert_eq!(find_invalid_xml_char("bad\x00char"), Some('\x00')); + } + + #[test] + fn test_find_invalid_xml_char_with_control() { + assert_eq!(find_invalid_xml_char("bad\x01char"), Some('\x01')); + assert_eq!(find_invalid_xml_char("bad\x08char"), Some('\x08')); + } + + #[test] + fn test_may_contain_invalid_xml_chars_fast_check() { + assert!(!may_contain_invalid_xml_chars(b"hello world")); + assert!(!may_contain_invalid_xml_chars(b"tab\there")); + assert!(!may_contain_invalid_xml_chars(b"newline\nhere")); + assert!(may_contain_invalid_xml_chars(b"bad\x00char")); + assert!(may_contain_invalid_xml_chars(b"bad\x01char")); + assert!(may_contain_invalid_xml_chars(b"\x7F")); // DEL + } +} diff --git a/browser/vendor/xmloxide/src/parser/mod.rs b/browser/vendor/xmloxide/src/parser/mod.rs new file mode 100644 index 000000000..b0ce7f67f --- /dev/null +++ b/browser/vendor/xmloxide/src/parser/mod.rs @@ -0,0 +1,243 @@ +//! XML 1.0 parser. +//! +//! A hand-rolled recursive descent parser conforming to the W3C XML 1.0 +//! (Fifth Edition) specification. The parser builds a `Document` tree and +//! supports error recovery mode for processing malformed input. +//! +//! The parser is hand-rolled (not combinator-based) because: +//! 1. libxml2's parser is recursive descent and we need identical behavior +//! 2. Error recovery requires fine-grained control over parse state +//! 3. Push/incremental parsing requires suspendable state +//! 4. Performance — no abstraction overhead + +pub(crate) mod input; +pub mod push; +mod xml; + +pub use push::PushParser; + +use std::sync::Arc; + +use crate::error::ParseError; +use crate::tree::Document; + +use input::{ + DEFAULT_MAX_ATTRIBUTES, DEFAULT_MAX_ATTRIBUTE_LENGTH, DEFAULT_MAX_DEPTH, + DEFAULT_MAX_ENTITY_EXPANSIONS, DEFAULT_MAX_NAME_LENGTH, DEFAULT_MAX_TEXT_LENGTH, +}; + +/// A request to resolve an external entity. +/// +/// Passed to the [`EntityResolver`] callback when the parser encounters +/// a reference to an externally-declared entity (SYSTEM or PUBLIC). +#[derive(Debug)] +pub struct ExternalEntityRequest<'a> { + /// The entity name as declared in the DTD. + pub name: &'a str, + /// The SYSTEM identifier (URI) from the entity declaration. + pub system_id: &'a str, + /// The PUBLIC identifier from the entity declaration, if any. + pub public_id: Option<&'a str>, +} + +/// A callback for resolving external entities. +/// +/// Returns `Some(replacement_text)` to expand the entity, or `None` to +/// reject the reference (which will produce a parse error or, in recovery +/// mode, an empty expansion). +/// +/// # Security +/// +/// **Warning:** Enabling external entity resolution opens the door to +/// XML External Entity (XXE) attacks. Only use this with trusted input, +/// and consider restricting which URIs the resolver is willing to fetch. +pub type EntityResolver = Arc<dyn Fn(ExternalEntityRequest<'_>) -> Option<String> + Send + Sync>; + +/// Parse options controlling parser behavior and security limits. +/// +/// Use the builder pattern to configure options: +/// +/// ``` +/// use xmloxide::parser::ParseOptions; +/// +/// let opts = ParseOptions::default() +/// .recover(true) +/// .no_blanks(true) +/// .max_depth(128); +/// ``` +pub struct ParseOptions { + /// If true, attempt to recover from errors and produce a partial tree. + pub recover: bool, + /// If true, strip ignorable whitespace-only text nodes. + pub no_blanks: bool, + + // -- Security limits -- + /// Maximum element nesting depth (default: 256). + pub max_depth: u32, + /// Maximum number of attributes on a single element (default: 256). + pub max_attributes: u32, + /// Maximum length in bytes of a single attribute value (default: 10 MB). + pub max_attribute_length: usize, + /// Maximum length in bytes of a single text node (default: 10 MB). + pub max_text_length: usize, + /// Maximum length in bytes of an element or attribute name (default: 50,000). + pub max_name_length: usize, + /// Maximum number of entity reference expansions per document (default: 10,000). + pub max_entity_expansions: u32, + /// Optional callback for resolving external entities. + /// + /// When set, references to externally-declared entities (SYSTEM/PUBLIC) + /// are passed to this resolver instead of producing an error. See + /// [`EntityResolver`] for security considerations. + pub entity_resolver: Option<EntityResolver>, +} + +impl Clone for ParseOptions { + fn clone(&self) -> Self { + Self { + recover: self.recover, + no_blanks: self.no_blanks, + max_depth: self.max_depth, + max_attributes: self.max_attributes, + max_attribute_length: self.max_attribute_length, + max_text_length: self.max_text_length, + max_name_length: self.max_name_length, + max_entity_expansions: self.max_entity_expansions, + entity_resolver: self.entity_resolver.clone(), + } + } +} + +impl std::fmt::Debug for ParseOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ParseOptions") + .field("recover", &self.recover) + .field("no_blanks", &self.no_blanks) + .field("max_depth", &self.max_depth) + .field("max_attributes", &self.max_attributes) + .field("max_attribute_length", &self.max_attribute_length) + .field("max_text_length", &self.max_text_length) + .field("max_name_length", &self.max_name_length) + .field("max_entity_expansions", &self.max_entity_expansions) + .field( + "entity_resolver", + &self.entity_resolver.as_ref().map(|_| "..."), + ) + .finish() + } +} + +impl Default for ParseOptions { + fn default() -> Self { + Self { + recover: false, + no_blanks: false, + max_depth: DEFAULT_MAX_DEPTH, + max_attributes: DEFAULT_MAX_ATTRIBUTES, + max_attribute_length: DEFAULT_MAX_ATTRIBUTE_LENGTH, + max_text_length: DEFAULT_MAX_TEXT_LENGTH, + max_name_length: DEFAULT_MAX_NAME_LENGTH, + max_entity_expansions: DEFAULT_MAX_ENTITY_EXPANSIONS, + entity_resolver: None, + } + } +} + +impl ParseOptions { + /// Enables or disables error recovery mode. + #[must_use] + pub fn recover(mut self, yes: bool) -> Self { + self.recover = yes; + self + } + + /// Enables or disables stripping of blank text nodes. + #[must_use] + pub fn no_blanks(mut self, yes: bool) -> Self { + self.no_blanks = yes; + self + } + + /// Sets the maximum element nesting depth. + #[must_use] + pub fn max_depth(mut self, max: u32) -> Self { + self.max_depth = max; + self + } + + /// Sets the maximum number of attributes per element. + #[must_use] + pub fn max_attributes(mut self, max: u32) -> Self { + self.max_attributes = max; + self + } + + /// Sets the maximum attribute value length in bytes. + #[must_use] + pub fn max_attribute_length(mut self, max: usize) -> Self { + self.max_attribute_length = max; + self + } + + /// Sets the maximum text node length in bytes. + #[must_use] + pub fn max_text_length(mut self, max: usize) -> Self { + self.max_text_length = max; + self + } + + /// Sets the maximum element/attribute name length in bytes. + #[must_use] + pub fn max_name_length(mut self, max: usize) -> Self { + self.max_name_length = max; + self + } + + /// Sets the maximum number of entity reference expansions. + #[must_use] + pub fn max_entity_expansions(mut self, max: u32) -> Self { + self.max_entity_expansions = max; + self + } + + /// Sets the external entity resolver callback. + /// + /// When set, references to externally-declared entities (SYSTEM/PUBLIC) + /// are passed to this callback for resolution. The callback receives an + /// [`ExternalEntityRequest`] and should return `Some(replacement_text)` + /// to expand the entity, or `None` to reject the reference. + /// + /// # Security + /// + /// **Warning:** Enabling external entity resolution opens the door to + /// XML External Entity (XXE) attacks. Only use this with trusted input, + /// and consider restricting which URIs the resolver is willing to fetch. + #[must_use] + pub fn entity_resolver( + mut self, + resolver: impl Fn(ExternalEntityRequest<'_>) -> Option<String> + Send + Sync + 'static, + ) -> Self { + self.entity_resolver = Some(Arc::new(resolver)); + self + } +} + +/// Parses an XML string with default options. +/// +/// # Errors +/// +/// Returns `ParseError` if the input is not well-formed XML. +pub fn parse_str(input: &str) -> Result<Document, ParseError> { + parse_str_with_options(input, &ParseOptions::default()) +} + +/// Parses an XML string with the given options. +/// +/// # Errors +/// +/// Returns `ParseError` if the input is not well-formed XML and recovery +/// mode is not enabled. +pub fn parse_str_with_options(input: &str, options: &ParseOptions) -> Result<Document, ParseError> { + let mut parser = xml::XmlParser::new(input, options); + parser.parse() +} diff --git a/browser/vendor/xmloxide/src/parser/push.rs b/browser/vendor/xmloxide/src/parser/push.rs new file mode 100644 index 000000000..a3f147d5b --- /dev/null +++ b/browser/vendor/xmloxide/src/parser/push.rs @@ -0,0 +1,509 @@ +//! Push/incremental XML parser. +//! +//! Provides a chunk-oriented parsing interface inspired by libxml2's push parser +//! (`xmlCreatePushParserCtxt` / `xmlParseChunk`). Data can be fed to the parser +//! in arbitrarily sized chunks via [`PushParser::push`], and the final document +//! is obtained by calling [`PushParser::finish`]. +//! +//! This is useful for scenarios where XML data arrives incrementally, such as +//! reading from a network socket or streaming from another process. +//! +//! # Design +//! +//! The current implementation buffers all pushed data internally and performs +//! the full parse on [`PushParser::finish`]. This provides correct chunk-boundary +//! handling with minimal complexity. A future optimization may parse eagerly +//! after each [`PushParser::push`] call. +//! +//! # Examples +//! +//! ``` +//! use xmloxide::parser::PushParser; +//! +//! let mut parser = PushParser::new(); +//! parser.push(b"<root>"); +//! parser.push(b"<child>Hello</child>"); +//! parser.push(b"</root>"); +//! +//! let doc = parser.finish().unwrap(); +//! let root = doc.root_element().unwrap(); +//! assert_eq!(doc.node_name(root), Some("root")); +//! ``` + +use crate::encoding::decode_to_utf8; +use crate::error::{ParseError, SourceLocation}; +use crate::parser::ParseOptions; +use crate::tree::Document; + +/// A push-based (incremental) XML parser. +/// +/// Accepts XML data in arbitrarily sized chunks and builds a [`Document`] tree +/// when parsing is finalized. This mirrors libxml2's push parser interface +/// (`xmlCreatePushParserCtxt` / `xmlParseChunk`). +/// +/// # Construction +/// +/// Use [`PushParser::new`] for default options, or [`PushParser::with_options`] +/// to configure parser behavior. +/// +/// # Examples +/// +/// Basic usage with multiple chunks: +/// +/// ``` +/// use xmloxide::parser::PushParser; +/// +/// let mut parser = PushParser::new(); +/// parser.push(b"<?xml version=\"1.0\"?>"); +/// parser.push(b"<root attr=\"value\">"); +/// parser.push(b"Hello, world!"); +/// parser.push(b"</root>"); +/// +/// let doc = parser.finish().unwrap(); +/// let root = doc.root_element().unwrap(); +/// assert_eq!(doc.node_name(root), Some("root")); +/// assert_eq!(doc.text_content(root), "Hello, world!"); +/// ``` +/// +/// With parse options: +/// +/// ``` +/// use xmloxide::parser::{ParseOptions, PushParser}; +/// +/// let opts = ParseOptions::default().recover(true); +/// let mut parser = PushParser::with_options(opts); +/// parser.push(b"<root>"); +/// parser.push(b"</root>"); +/// +/// let doc = parser.finish().unwrap(); +/// assert!(doc.root_element().is_some()); +/// ``` +pub struct PushParser { + /// Accumulated raw bytes from all `push()` calls. + buffer: Vec<u8>, + /// Parser options. + options: ParseOptions, + /// Whether `finish()` has already been called. + finished: bool, +} + +impl PushParser { + /// Creates a new push parser with default options. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::parser::PushParser; + /// + /// let mut parser = PushParser::new(); + /// parser.push(b"<root/>"); + /// let doc = parser.finish().unwrap(); + /// ``` + #[must_use] + pub fn new() -> Self { + Self { + buffer: Vec::new(), + options: ParseOptions::default(), + finished: false, + } + } + + /// Creates a new push parser with the specified options. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::parser::{ParseOptions, PushParser}; + /// + /// let parser = PushParser::with_options( + /// ParseOptions::default().recover(true).no_blanks(true), + /// ); + /// ``` + #[must_use] + pub fn with_options(options: ParseOptions) -> Self { + Self { + buffer: Vec::new(), + options, + finished: false, + } + } + + /// Feeds a chunk of raw XML bytes into the parser. + /// + /// Data is accumulated in an internal buffer. The chunk can be any size + /// and may split tokens, elements, or even multi-byte characters at + /// arbitrary boundaries. + /// + /// # Panics + /// + /// Panics if called after [`finish`](PushParser::finish) has been invoked. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::parser::PushParser; + /// + /// let mut parser = PushParser::new(); + /// parser.push(b"<ro"); + /// parser.push(b"ot/>"); + /// let doc = parser.finish().unwrap(); + /// ``` + pub fn push(&mut self, data: &[u8]) { + assert!( + !self.finished, + "push() called after finish() — parser has already been consumed" + ); + self.buffer.extend_from_slice(data); + } + + /// Finalizes parsing and returns the constructed [`Document`]. + /// + /// This consumes the parser. All buffered data is decoded (with automatic + /// encoding detection) and parsed as a complete XML document. + /// + /// # Errors + /// + /// Returns [`ParseError`] if the accumulated data is not well-formed XML + /// (unless recovery mode is enabled via [`ParseOptions::recover`]). + /// + /// # Examples + /// + /// ``` + /// use xmloxide::parser::PushParser; + /// + /// let mut parser = PushParser::new(); + /// parser.push(b"<root><child/></root>"); + /// let doc = parser.finish().unwrap(); + /// ``` + pub fn finish(mut self) -> Result<Document, ParseError> { + self.finished = true; + + let utf8 = decode_to_utf8(&self.buffer).map_err(|e| ParseError { + message: e.message, + location: SourceLocation::default(), + diagnostics: Vec::new(), + })?; + + crate::parser::parse_str_with_options(&utf8, &self.options) + } + + /// Returns the number of bytes currently buffered. + /// + /// This is the total number of bytes received via [`push`](PushParser::push) + /// that have not yet been parsed (parsing occurs on [`finish`](PushParser::finish)). + /// + /// # Examples + /// + /// ``` + /// use xmloxide::parser::PushParser; + /// + /// let mut parser = PushParser::new(); + /// assert_eq!(parser.buffered_bytes(), 0); + /// parser.push(b"<root/>"); + /// assert_eq!(parser.buffered_bytes(), 7); + /// ``` + #[must_use] + pub fn buffered_bytes(&self) -> usize { + self.buffer.len() + } + + /// Returns `true` if no data has been pushed yet. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::parser::PushParser; + /// + /// let mut parser = PushParser::new(); + /// assert!(parser.is_empty()); + /// parser.push(b"<root/>"); + /// assert!(!parser.is_empty()); + /// ``` + #[must_use] + pub fn is_empty(&self) -> bool { + self.buffer.is_empty() + } + + /// Resets the parser, discarding all buffered data. + /// + /// After calling this method, the parser is in the same state as a + /// newly created one (with the same options). This allows reusing + /// the parser for a new document without allocating a new instance. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::parser::PushParser; + /// + /// let mut parser = PushParser::new(); + /// parser.push(b"<root/>"); + /// parser.reset(); + /// assert!(parser.is_empty()); + /// parser.push(b"<other/>"); + /// let doc = parser.finish().unwrap(); + /// ``` + pub fn reset(&mut self) { + self.buffer.clear(); + self.finished = false; + } +} + +impl Default for PushParser { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Debug for PushParser { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PushParser") + .field("buffered_bytes", &self.buffer.len()) + .field("options", &self.options) + .field("finished", &self.finished) + .finish() + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn test_push_parser_single_chunk() { + let mut parser = PushParser::new(); + parser.push(b"<root/>"); + let doc = parser.finish().unwrap(); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_name(root), Some("root")); + } + + #[test] + fn test_push_parser_multiple_chunks() { + let mut parser = PushParser::new(); + parser.push(b"<root>"); + parser.push(b"<child>text</child>"); + parser.push(b"</root>"); + let doc = parser.finish().unwrap(); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_name(root), Some("root")); + + let child = doc.first_child(root).unwrap(); + assert_eq!(doc.node_name(child), Some("child")); + assert_eq!(doc.text_content(child), "text"); + } + + #[test] + fn test_push_parser_split_token() { + // Split an element tag across chunk boundaries. + let mut parser = PushParser::new(); + parser.push(b"<ro"); + parser.push(b"ot att"); + parser.push(b"r=\"val"); + parser.push(b"ue\"/>"); + let doc = parser.finish().unwrap(); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_name(root), Some("root")); + assert_eq!(doc.attribute(root, "attr"), Some("value")); + } + + #[test] + fn test_push_parser_byte_at_a_time() { + let xml = b"<root><child/></root>"; + let mut parser = PushParser::new(); + for &byte in xml { + parser.push(&[byte]); + } + let doc = parser.finish().unwrap(); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_name(root), Some("root")); + let child = doc.first_child(root).unwrap(); + assert_eq!(doc.node_name(child), Some("child")); + } + + #[test] + fn test_push_parser_xml_declaration_split() { + let mut parser = PushParser::new(); + parser.push(b"<?xml ver"); + parser.push(b"sion=\"1.0\" encoding=\"UTF-8\"?>"); + parser.push(b"<root/>"); + let doc = parser.finish().unwrap(); + assert_eq!(doc.version.as_deref(), Some("1.0")); + assert_eq!(doc.encoding.as_deref(), Some("UTF-8")); + } + + #[test] + fn test_push_parser_empty_input() { + let parser = PushParser::new(); + let result = parser.finish(); + // XML 1.0 §2.1 requires a root element + assert!(result.is_err()); + } + + #[test] + fn test_push_parser_with_options_recover() { + let opts = ParseOptions::default().recover(true); + let mut parser = PushParser::with_options(opts); + // Mismatched tags — should succeed in recovery mode. + parser.push(b"<a></b>"); + let result = parser.finish(); + assert!(result.is_ok()); + } + + #[test] + fn test_push_parser_with_options_no_blanks() { + let opts = ParseOptions::default().no_blanks(true); + let mut parser = PushParser::with_options(opts); + parser.push(b"<root> <child/> </root>"); + let doc = parser.finish().unwrap(); + let root = doc.root_element().unwrap(); + // The blank text nodes (" ") should have been stripped. + let children: Vec<_> = doc.children(root).collect(); + assert_eq!(children.len(), 1); + assert_eq!(doc.node_name(children[0]), Some("child")); + } + + #[test] + fn test_push_parser_error_malformed() { + let mut parser = PushParser::new(); + parser.push(b"<a></b>"); + let result = parser.finish(); + assert!(result.is_err()); + } + + #[test] + fn test_push_parser_buffered_bytes() { + let mut parser = PushParser::new(); + assert_eq!(parser.buffered_bytes(), 0); + parser.push(b"<root>"); + assert_eq!(parser.buffered_bytes(), 6); + parser.push(b"</root>"); + assert_eq!(parser.buffered_bytes(), 13); + } + + #[test] + fn test_push_parser_is_empty() { + let mut parser = PushParser::new(); + assert!(parser.is_empty()); + parser.push(b"<root/>"); + assert!(!parser.is_empty()); + } + + #[test] + fn test_push_parser_reset() { + let mut parser = PushParser::new(); + parser.push(b"<invalid"); + parser.reset(); + assert!(parser.is_empty()); + assert_eq!(parser.buffered_bytes(), 0); + parser.push(b"<root/>"); + let doc = parser.finish().unwrap(); + assert!(doc.root_element().is_some()); + } + + #[test] + fn test_push_parser_default_trait() { + let parser = PushParser::default(); + assert!(parser.is_empty()); + } + + #[test] + fn test_push_parser_debug_trait() { + let mut parser = PushParser::new(); + parser.push(b"<root/>"); + let debug_str = format!("{parser:?}"); + assert!(debug_str.contains("PushParser")); + assert!(debug_str.contains("buffered_bytes: 7")); + } + + #[test] + fn test_push_parser_utf8_bom() { + let mut parser = PushParser::new(); + // Push UTF-8 BOM followed by XML. + parser.push(b"\xEF\xBB\xBF"); + parser.push(b"<root/>"); + let doc = parser.finish().unwrap(); + assert!(doc.root_element().is_some()); + } + + #[test] + fn test_push_parser_comment_split() { + let mut parser = PushParser::new(); + parser.push(b"<root><!-"); + parser.push(b"- comment -"); + parser.push(b"-></root>"); + let doc = parser.finish().unwrap(); + let root = doc.root_element().unwrap(); + let child = doc.first_child(root).unwrap(); + assert_eq!(doc.node_text(child), Some(" comment ")); + } + + #[test] + fn test_push_parser_cdata_split() { + let mut parser = PushParser::new(); + parser.push(b"<root><![CDA"); + parser.push(b"TA[some data]]"); + parser.push(b"></root>"); + let doc = parser.finish().unwrap(); + let root = doc.root_element().unwrap(); + let child = doc.first_child(root).unwrap(); + assert_eq!(doc.node_text(child), Some("some data")); + } + + #[test] + fn test_push_parser_entity_references() { + let mut parser = PushParser::new(); + parser.push(b"<root>&am"); + parser.push(b"p; &lt; &gt;</root>"); + let doc = parser.finish().unwrap(); + let root = doc.root_element().unwrap(); + assert_eq!(doc.text_content(root), "& < >"); + } + + #[test] + fn test_push_parser_roundtrip() { + let input = b"<root><child attr=\"val\">text</child></root>"; + let mut parser = PushParser::new(); + parser.push(&input[..10]); + parser.push(&input[10..25]); + parser.push(&input[25..]); + let doc = parser.finish().unwrap(); + let output = crate::serial::serialize(&doc); + let expected = format!( + "<?xml version=\"1.0\"?>\n{}\n", + std::str::from_utf8(input).unwrap() + ); + assert_eq!(output, expected); + } + + #[test] + #[should_panic(expected = "push() called after finish()")] + fn test_push_parser_push_after_finish_panics() { + let mut parser = PushParser::new(); + parser.push(b"<root/>"); + // Simulate calling push after finish by using an unsafe trick. + // Actually we need to keep a reference — but finish() consumes self. + // The assertion in push() uses self.finished, so we test indirectly. + // + // We cannot directly test this because `finish()` takes self by value. + // Instead, test the assert fires when finished is set. + parser.finished = true; + parser.push(b"more data"); + } + + #[test] + fn test_push_parser_large_document() { + let mut parser = PushParser::new(); + parser.push(b"<root>"); + for i in 0..100 { + let chunk = format!("<item id=\"{i}\">value {i}</item>"); + parser.push(chunk.as_bytes()); + } + parser.push(b"</root>"); + + let doc = parser.finish().unwrap(); + let root = doc.root_element().unwrap(); + let children: Vec<_> = doc.children(root).collect(); + assert_eq!(children.len(), 100); + } +} diff --git a/browser/vendor/xmloxide/src/parser/xml.rs b/browser/vendor/xmloxide/src/parser/xml.rs new file mode 100644 index 000000000..494d8464c --- /dev/null +++ b/browser/vendor/xmloxide/src/parser/xml.rs @@ -0,0 +1,2176 @@ +//! Core XML 1.0 parser state machine. +//! +//! Implements a hand-rolled recursive descent parser for XML 1.0 (Fifth Edition). +//! See <https://www.w3.org/TR/xml/> for the specification. + +use std::collections::HashMap; + +use crate::error::{ErrorSeverity, ParseError}; +use crate::tree::{Attribute, Document, NodeId, NodeKind}; +use crate::validation::dtd::{parse_dtd, serialize_dtd, AttributeDecl, AttributeType, EntityKind}; + +use super::input::{ + find_invalid_xml_char, may_contain_invalid_xml_chars, parse_cdata_content, + parse_comment_content, parse_pi_content, parse_xml_decl, split_name, split_owned_name, + validate_pubid, ExternalEntityInfo, NamespaceResolver, ParserInput, XMLNS_NAMESPACE, + XML_NAMESPACE, +}; +use super::ParseOptions; + +/// Default maximum amplification factor for entity/attribute expansion. +/// +/// libxml2 uses a factor of 5 — if the expanded output exceeds 5x the input +/// size due to default attribute application or entity expansion, the document +/// is rejected as a potential denial-of-service attack. +const DEFAULT_MAX_AMPLIFICATION: usize = 5; + +/// Maximum nesting depth of general entity expansion in content. +/// +/// Bounds the recursion when an entity's replacement text references other +/// entities (XML 1.0 §4.4 "Included"). Recursion is already rejected by the +/// DTD parser (WFC: No Recursion), so this is defense-in-depth for +/// resolver-provided external entities and stack safety. +const MAX_ENTITY_DEPTH: u32 = 32; + +/// The core XML parser. +pub(crate) struct XmlParser<'a> { + /// Shared low-level input state (position, peek, advance, name parsing, etc.). + input: ParserInput<'a>, + /// The document being built. + doc: Document, + /// Parser options. + options: ParseOptions, + /// Namespace resolver managing the scope stack. + ns: NamespaceResolver, + /// DTD attribute type declarations, keyed by `(element_name, attr_name)`. + /// Used for attribute value normalization of namespace URIs. + attr_types: HashMap<(String, String), AttributeType>, + /// DTD attribute default value declarations, keyed by element name. + /// Used for applying default/fixed attributes from ATTLIST. + attr_defaults: HashMap<String, Vec<AttributeDecl>>, + /// Total input size in bytes, used for amplification factor checking. + input_size: usize, + /// Running total of bytes added through default attribute expansion. + expansion_size: usize, + /// Current nesting depth of general entity expansion (0 = document level). + entity_depth: u32, +} + +impl<'a> XmlParser<'a> { + pub fn new(input: &'a str, options: &ParseOptions) -> Self { + let mut pi = ParserInput::new(input); + pi.set_recover(options.recover); + pi.set_max_depth(options.max_depth); + pi.set_max_name_length(options.max_name_length); + pi.set_max_entity_expansions(options.max_entity_expansions); + pi.set_entity_resolver(options.entity_resolver.clone()); + + // Pre-size the node arena — roughly 1 node per 30 bytes of input. + let estimated_nodes = (input.len() / 30).max(64); + Self { + input: pi, + doc: Document::with_capacity(estimated_nodes), + options: options.clone(), + ns: NamespaceResolver::new(), + attr_types: HashMap::new(), + attr_defaults: HashMap::new(), + input_size: input.len(), + expansion_size: 0, + entity_depth: 0, + } + } + + /// Main parse entry point. Parses the entire document. + pub fn parse(&mut self) -> Result<Document, ParseError> { + // Parse optional XML declaration — must be at the very start of the + // document with no leading whitespace (XML 1.0 §2.8). + if self.input.looking_at(b"<?xml ") + || self.input.looking_at(b"<?xml\t") + || self.input.looking_at(b"<?xml\r") + { + self.parse_xml_declaration()?; + // Skip whitespace immediately after the XML declaration — the + // serializer always emits its own newline after the declaration. + self.input.skip_whitespace(); + } else if !self.input.at_end() { + // If there's no XML declaration, skip any leading whitespace. + // Leading whitespace before a non-declaration is tolerated + // (it will be handled as misc content). + let had_leading_ws = self.input.skip_whitespace(); + // But if the whitespace was hiding an XML declaration, that's an error + if had_leading_ws + && (self.input.looking_at(b"<?xml ") + || self.input.looking_at(b"<?xml\t") + || self.input.looking_at(b"<?xml\r")) + { + return Err(self + .input + .fatal("XML declaration must be at the start of the document")); + } + } + + // Parse prolog content (comments, PIs, whitespace before root element) + self.parse_misc(self.doc.root())?; + + // Parse optional DOCTYPE declaration + if self.input.looking_at(b"<!DOCTYPE") || self.input.looking_at(b"<!doctype") { + self.parse_doctype(self.doc.root())?; + self.parse_misc(self.doc.root())?; // more misc after doctype + } + + // Parse root element (required by XML 1.0 §2.1) + if self.input.peek() == Some(b'<') + && self + .input + .peek_at(1) + .is_some_and(|b| b != b'!' && b != b'?') + { + self.parse_element(self.doc.root())?; + } else if self.options.recover { + self.input + .push_diagnostic(ErrorSeverity::Error, "missing root element".to_string()); + } else { + return Err(self.input.fatal("missing root element")); + } + + // Parse trailing content (comments, PIs after root element) + self.parse_misc(self.doc.root())?; + + self.input.skip_whitespace(); + if !self.input.at_end() && !self.options.recover { + return Err(self.input.fatal("content after document element")); + } + + // Sync diagnostics from input to document before returning. + self.doc.diagnostics = std::mem::take(&mut self.input.diagnostics); + + Ok(std::mem::take(&mut self.doc)) + } + + // --- XML Declaration --- + // See XML 1.0 §2.8: [23] XMLDecl + + fn parse_xml_declaration(&mut self) -> Result<(), ParseError> { + let decl = parse_xml_decl(&mut self.input)?; + self.doc.version = Some(decl.version); + self.doc.encoding = decl.encoding; + self.doc.standalone = decl.standalone; + Ok(()) + } + + // --- Misc (comments, PIs, whitespace) --- + + fn parse_misc(&mut self, parent: NodeId) -> Result<(), ParseError> { + loop { + // Preserve document-level whitespace as text nodes (matches libxml2). + // libxml2 normalizes prolog/epilog whitespace to a single `\n` + // regardless of how many blank lines appear in the source. + let ws = self.input.consume_whitespace(); + if !ws.is_empty() { + let ws_node = self.doc.create_node(NodeKind::Text { + content: "\n".to_string(), + }); + self.doc.append_child(parent, ws_node); + } + if self.input.at_end() { + break; + } + if self.input.looking_at(b"<!--") { + self.parse_comment(parent)?; + } else if self.input.looking_at(b"<?") { + self.parse_processing_instruction(parent)?; + } else { + break; + } + } + Ok(()) + } + + // --- DOCTYPE Declaration --- + // See XML 1.0 §2.8: [28] doctypedecl + + #[allow(clippy::too_many_lines)] + fn parse_doctype(&mut self, parent: NodeId) -> Result<(), ParseError> { + // Consume <!DOCTYPE (case-insensitive match already checked by caller) + self.input.expect_str(b"<!DOCTYPE")?; + self.input.skip_whitespace_required()?; + + // Read the root element name + let name = self.input.parse_name()?; + + self.input.skip_whitespace(); + + // Check for external ID: SYSTEM or PUBLIC + let mut system_id = None; + let mut public_id = None; + + if self.input.looking_at(b"SYSTEM") { + self.input.expect_str(b"SYSTEM")?; + self.input.skip_whitespace_required()?; + system_id = Some(self.input.parse_quoted_value()?); + self.input.skip_whitespace(); + } else if self.input.looking_at(b"PUBLIC") { + self.input.expect_str(b"PUBLIC")?; + self.input.skip_whitespace_required()?; + let pid = self.input.parse_quoted_value()?; + // Validate public ID characters per XML 1.0 §2.3 [13]. + if let Some(msg) = validate_pubid(&pid) { + if self.options.recover { + self.input.push_diagnostic(ErrorSeverity::Warning, msg); + } else { + return Err(self.input.fatal(msg)); + } + } + public_id = Some(pid); + self.input.skip_whitespace_required()?; + system_id = Some(self.input.parse_quoted_value()?); + self.input.skip_whitespace(); + } + + // Flag whether there's an external DTD subset. Per XML 1.0 §4.1 WFC: + // Entity Declared, undeclared entities are not WF errors when the + // document references an unread external DTD subset. + if system_id.is_some() || public_id.is_some() { + self.input.has_external_dtd = true; + } + + // Parse optional internal subset: [ ... ] + let mut internal_subset = None; + if self.input.peek() == Some(b'[') { + self.input.advance(1); + let start = self.input.pos(); + + // Scan to matching ']', tracking depth for bracket chars inside + // entity values. Quoted strings and comments are skipped to avoid + // misinterpreting brackets or apostrophes in comments. + let mut depth: u32 = 1; + while !self.input.at_end() && depth > 0 { + if self.input.looking_at(b"<!--") { + // Skip XML comments (may contain apostrophes/quotes) + self.input.advance(4); + while !self.input.at_end() && !self.input.looking_at(b"-->") { + self.input.advance(1); + } + if !self.input.at_end() { + self.input.advance(3); // consume --> + } + } else if let Some(b'"' | b'\'') = self.input.peek() { + let quote = self.input.peek().unwrap_or(b'"'); + self.input.advance(1); + while !self.input.at_end() && self.input.peek() != Some(quote) { + self.input.advance(1); + } + if !self.input.at_end() { + self.input.advance(1); // closing quote + } + } else if self.input.peek() == Some(b'[') { + depth += 1; + self.input.advance(1); + } else if self.input.peek() == Some(b']') { + depth -= 1; + self.input.advance(1); + } else { + self.input.advance(1); + } + } + if depth > 0 { + return Err(self + .input + .fatal("unexpected end of input in internal subset")); + } + + // Extract the internal subset text (between '[' and ']'). + let end = self.input.pos() - 1; // exclude the closing ']' + let subset_text = std::str::from_utf8(self.input.slice(start, end)) + .ok() + .map(str::to_string); + + if let Some(subset_text) = subset_text { + // Detect parameter entity references in the internal subset. + // Per XML 1.0 §4.1 WFC: Entity Declared, their presence + // means undeclared general entities are not WF errors. + if subset_text.contains('%') { + self.input.has_pe_references = true; + } + + match parse_dtd(&subset_text) { + Ok(dtd) => { + // Wire entity declarations into the parser input + // for entity reference resolution. + for (ent_name, ent_decl) in &dtd.entities { + match &ent_decl.kind { + EntityKind::Internal(value) => { + self.input + .entity_map + .insert(ent_name.clone(), value.clone()); + } + EntityKind::External { + system_id, + public_id, + } => { + self.input.entity_external.insert( + ent_name.clone(), + ExternalEntityInfo { + system_id: system_id.clone(), + public_id: public_id.clone(), + }, + ); + } + } + } + + // Wire attribute type declarations for namespace + // URI normalization (XML 1.0 §3.3.3). + for (element_name, attrs) in &dtd.attributes { + for attr_decl in attrs { + self.attr_types.insert( + (element_name.clone(), attr_decl.attribute_name.clone()), + attr_decl.attribute_type.clone(), + ); + } + } + + // Store attribute default values for later application. + for (element_name, attrs) in &dtd.attributes { + for attr_decl in attrs { + self.attr_defaults + .entry(element_name.clone()) + .or_default() + .push(attr_decl.clone()); + } + } + + // Re-serialize the DTD from parsed structures for + // consistent formatting (matches libxml2 behavior). + let serialized = serialize_dtd(&dtd); + if !serialized.is_empty() { + internal_subset = Some(serialized); + } + } + Err(e) => { + if self.options.recover { + self.input.push_diagnostic( + ErrorSeverity::Warning, + format!("error parsing DTD internal subset: {}", e.message), + ); + } else { + return Err(self + .input + .fatal(format!("error in DTD internal subset: {}", e.message))); + } + } + } + } + + self.input.skip_whitespace(); + } + + self.input.expect_byte(b'>')?; + + let doctype_id = self.doc.create_node(NodeKind::DocumentType { + name, + system_id, + public_id, + internal_subset, + }); + self.doc.append_child(parent, doctype_id); + Ok(()) + } + + // --- Elements --- + // See XML 1.0 §3.1: [40] STag, [42] ETag, [44] EmptyElemTag + + #[allow(clippy::too_many_lines)] + fn parse_element(&mut self, parent: NodeId) -> Result<NodeId, ParseError> { + self.input.increment_depth()?; + self.input.expect_byte(b'<')?; + let name = self.input.parse_name()?; + let mut attributes = Vec::new(); + + // Parse attributes + loop { + let had_ws = self.input.skip_whitespace(); + if self.input.peek() == Some(b'>') || self.input.looking_at(b"/>") { + break; + } + if !had_ws { + return Err(self.input.fatal("whitespace required between attributes")); + } + let attr = self.parse_attribute()?; + attributes.push(attr); + } + + // Check for duplicate attributes (XML 1.0 §3.1 WFC: Unique Att Spec) + // Skip for 0 or 1 attributes (no duplicates possible). + if attributes.len() >= 2 { + // O(n²) comparison avoids HashSet allocation for small attribute lists. + let mut found_dup = false; + 'outer: for i in 1..attributes.len() { + for j in 0..i { + if attributes[i].name == attributes[j].name + && attributes[i].prefix == attributes[j].prefix + { + let full_name = if let Some(ref pfx) = attributes[i].prefix { + format!("{pfx}:{}", attributes[i].name) + } else { + attributes[i].name.clone() + }; + if self.options.recover { + self.input.push_diagnostic( + ErrorSeverity::Error, + format!("duplicate attribute: '{full_name}'"), + ); + found_dup = true; + } else { + return Err(self + .input + .fatal(format!("duplicate attribute: '{full_name}'"))); + } + break 'outer; + } + } + } + let _ = found_dup; // suppress unused warning + } + + // --- Apply DTD ATTLIST default attributes (#FIXED and #DEFAULT) --- + // Per XML 1.0 §3.3.2, when an attribute declared in an ATTLIST is not + // present on the element, the parser must add it with the declared + // default value. This applies to both `#FIXED "v"` and bare `"v"` + // (so-called #DEFAULT) declarations. libxml2 applies both during + // normal parsing — verifiable via `xmllint --c14n` on a document + // with an ATTLIST default, which emits the default attribute in the + // canonical form. + // Namespace declarations (xmlns, xmlns:prefix) are inserted before + // other attributes to match libxml2's attribute ordering. + if let Some(defaults) = if self.attr_defaults.is_empty() { + None + } else { + self.attr_defaults.get(&name).cloned() + } { + let mut insert_pos = 0; // insertion point for namespace declarations + for attr_decl in &defaults { + let (default_value, is_fixed) = match &attr_decl.default { + crate::validation::dtd::AttributeDefault::Fixed(v) => (Some(v.clone()), true), + crate::validation::dtd::AttributeDefault::Default(v) => { + (Some(v.clone()), false) + } + _ => (None, false), + }; + if let Some(value) = default_value { + // Check if the attribute is already present by comparing + // prefix:local components directly, avoiding format!/clone. + let attr_name = &attr_decl.attribute_name; + let (decl_pfx, decl_local) = split_name(attr_name); + let already_present = attributes + .iter() + .any(|a| a.name == decl_local && a.prefix.as_deref() == decl_pfx); + if !already_present { + // Track expansion for amplification factor check + // (both #FIXED and #DEFAULT contribute to expansion). + self.expansion_size += attr_name.len() + value.len(); + + // Insert both #FIXED and #DEFAULT defaults into the + // tree. The `is_fixed` flag is no longer used to gate + // insertion; it would only matter if we additionally + // validated source attributes against #FIXED values, + // which is a separate validation step. + let _ = is_fixed; + let (decl_prefix, decl_local) = split_name(attr_name); + let attr = Attribute { + name: decl_local.to_string(), + value, + prefix: decl_prefix.map(String::from), + namespace: None, + raw_value: None, + }; + let is_ns_decl = attr_name == "xmlns" || attr_name.starts_with("xmlns:"); + if is_ns_decl { + attributes.insert(insert_pos, attr); + insert_pos += 1; + } else { + attributes.push(attr); + } + } + } + } + + // Check amplification factor: reject if default attribute + // expansion would exceed the input size by more than the + // maximum factor (matching libxml2's xmlCtxtSetMaxAmplification). + if self.expansion_size > self.input_size.saturating_mul(DEFAULT_MAX_AMPLIFICATION) { + return Err(self + .input + .fatal("maximum entity amplification factor exceeded")); + } + } + + // --- Namespace processing (Namespaces in XML 1.0 section 3) --- + + // Check for namespace declarations to skip namespace scope push/pop + // when not needed. Skip the scan entirely when there are no attributes. + let has_ns_decls = !attributes.is_empty() + && attributes.iter().any(|a| { + a.prefix.as_deref() == Some("xmlns") || (a.prefix.is_none() && a.name == "xmlns") + }); + if has_ns_decls { + self.ns.push_scope(); + } + + // Split into prefix and local name for namespace processing. + let (prefix, local_name) = split_name(&name); + + // Validate QName syntax: check for multiple colons (the local part + // should not contain a colon after split_name). + if prefix.is_some() && local_name.contains(':') { + let msg = "QName contains multiple colons"; + if self.options.recover { + self.input + .push_diagnostic(ErrorSeverity::Error, msg.to_string()); + } else { + return Err(self.input.fatal(msg)); + } + } + + // Reject element names with "xmlns" prefix (Namespaces in XML 1.0 §3). + if prefix == Some("xmlns") { + if self.options.recover { + self.input.push_diagnostic( + ErrorSeverity::Error, + "elements must not have the prefix 'xmlns'".to_string(), + ); + } else { + return Err(self + .input + .fatal("elements must not have the prefix 'xmlns'")); + } + } + + // Scan attributes for namespace declarations and bind them, + // with validation of namespace constraints. + // In recovery mode, some invalid attributes may be stripped. + let mut strip_attr_indices: Vec<usize> = Vec::new(); + if has_ns_decls { + for (attr_idx, attr) in attributes.iter().enumerate() { + if attr.prefix.as_deref() == Some("xmlns") { + // Prefixed namespace declaration: xmlns:prefix="uri" + let declared_prefix = &attr.name; + + // Validate QName: the local part (declared_prefix) must be + // a non-empty NCName (no colon, not empty). An empty local + // part means the attribute was `xmlns:` with nothing after + // the colon, which is not a valid QName. + if declared_prefix.is_empty() { + let msg = "namespace prefix must not be empty (invalid QName 'xmlns:')"; + if self.options.recover { + self.input + .push_diagnostic(ErrorSeverity::Error, msg.to_string()); + continue; + } + return Err(self.input.fatal(msg)); + } + if declared_prefix.contains(':') { + let msg = "QName contains multiple colons"; + if self.options.recover { + self.input + .push_diagnostic(ErrorSeverity::Error, msg.to_string()); + } else { + return Err(self.input.fatal(msg)); + } + } + + // Normalize namespace URI based on DTD-declared attribute + // type. For non-CDATA types (e.g., NMTOKEN), whitespace is + // collapsed per XML 1.0 §3.3.3. Construct the full attribute + // name only when attr_types is non-empty (DTD present). + let ns_value = if self.attr_types.is_empty() { + attr.value.clone() + } else { + let attr_qname = format!("xmlns:{declared_prefix}"); + self.normalize_attr_value_by_type(&name, &attr_qname, &attr.value) + }; + + // XML 1.0 Namespaces: cannot unbind a prefix (xmlns:prefix=""). + if ns_value.is_empty() { + if self.options.recover { + self.input.push_diagnostic( + ErrorSeverity::Error, + format!("namespace prefix '{declared_prefix}' cannot be undeclared in XML 1.0"), + ); + } else { + return Err(self.input.fatal(format!( + "namespace prefix '{declared_prefix}' cannot be undeclared in XML 1.0" + ))); + } + } + + // Cannot declare the 'xmlns' prefix itself. + if declared_prefix == "xmlns" { + if self.options.recover { + self.input.push_diagnostic( + ErrorSeverity::Error, + "the 'xmlns' prefix must not be declared".to_string(), + ); + } else { + return Err(self + .input + .fatal("the 'xmlns' prefix must not be declared")); + } + } + + // 'xml' prefix must map to the XML namespace URI and vice versa. + if declared_prefix == "xml" && ns_value != XML_NAMESPACE { + if self.options.recover { + self.input.push_diagnostic( + ErrorSeverity::Error, + "the 'xml' prefix must be bound to the XML namespace".to_string(), + ); + // In recovery mode, strip the invalid rebinding + // (matches libxml2: output is <tst/> not <tst xmlns:xml="..."/>). + strip_attr_indices.push(attr_idx); + continue; + } + return Err(self + .input + .fatal("the 'xml' prefix must be bound to the XML namespace")); + } + + // No other prefix may be bound to the XML namespace URI. + if declared_prefix != "xml" && ns_value == XML_NAMESPACE { + if self.options.recover { + self.input.push_diagnostic( + ErrorSeverity::Error, + "only the 'xml' prefix may be bound to the XML namespace" + .to_string(), + ); + } else { + return Err(self + .input + .fatal("only the 'xml' prefix may be bound to the XML namespace")); + } + } + + // No prefix may be bound to the xmlns namespace URI. + if ns_value == XMLNS_NAMESPACE { + if self.options.recover { + self.input.push_diagnostic( + ErrorSeverity::Error, + "the xmlns namespace must not be bound to any prefix".to_string(), + ); + } else { + return Err(self + .input + .fatal("the xmlns namespace must not be bound to any prefix")); + } + } + + self.ns.bind(Some(attr.name.clone()), ns_value); + } else if attr.prefix.is_none() && attr.name == "xmlns" { + // Default namespace declaration: xmlns="uri" + + // Normalize namespace URI based on DTD-declared attribute type. + let ns_value = if self.attr_types.is_empty() { + attr.value.clone() + } else { + self.normalize_attr_value_by_type(&name, "xmlns", &attr.value) + }; + + // Cannot bind default namespace to the XML or xmlns namespace URIs. + if ns_value == XML_NAMESPACE { + if self.options.recover { + self.input.push_diagnostic( + ErrorSeverity::Error, + "the xml namespace must not be declared as the default namespace" + .to_string(), + ); + } else { + return Err(self.input.fatal( + "the xml namespace must not be declared as the default namespace", + )); + } + } + if ns_value == XMLNS_NAMESPACE { + if self.options.recover { + self.input.push_diagnostic( + ErrorSeverity::Error, + "the xmlns namespace must not be declared as the default namespace" + .to_string(), + ); + } else { + return Err(self.input.fatal( + "the xmlns namespace must not be declared as the default namespace", + )); + } + } + self.ns.bind(None, ns_value); + } else if attr.prefix.is_some() && attr.name.contains(':') { + // Validate QName syntax for prefixed non-namespace attributes. + let msg = "QName contains multiple colons"; + if self.options.recover { + self.input + .push_diagnostic(ErrorSeverity::Error, msg.to_string()); + } else { + return Err(self.input.fatal(msg)); + } + } + } + } else { + // No namespace declarations — only validate QName syntax for + // prefixed attributes (checking for multiple colons). + for attr in &attributes { + if attr.prefix.is_some() && attr.name.contains(':') { + let msg = "QName contains multiple colons"; + if self.options.recover { + self.input + .push_diagnostic(ErrorSeverity::Error, msg.to_string()); + } else { + return Err(self.input.fatal(msg)); + } + } + } + } + + // Resolve the element's namespace URI from its prefix. + let elem_ns = self.ns.resolve(prefix).map(String::from); + + // Check for unbound element prefix. + if let Some(pfx) = prefix { + if pfx != "xml" && elem_ns.is_none() { + if self.options.recover { + self.input.push_diagnostic( + ErrorSeverity::Error, + format!("unbound namespace prefix '{pfx}'"), + ); + } else { + return Err(self + .input + .fatal(format!("unbound namespace prefix '{pfx}'"))); + } + } + } + + // Resolve namespace URIs for non-xmlns prefixed attributes. + // Unprefixed attributes do NOT inherit the default namespace (per spec). + // Skip entirely when there are no prefixed non-xmlns attributes. + let has_prefixed_attrs = !attributes.is_empty() + && attributes + .iter() + .any(|a| a.prefix.is_some() && a.prefix.as_deref() != Some("xmlns")); + if has_prefixed_attrs { + for attr in &mut attributes { + if let Some(pfx) = &attr.prefix { + if pfx == "xmlns" { + continue; // namespace declaration, not a real attribute prefix + } + let resolved = self.ns.resolve(Some(pfx.as_str())).map(String::from); + if pfx != "xml" && resolved.is_none() { + if self.options.recover { + self.input.push_diagnostic( + ErrorSeverity::Error, + format!("unbound namespace prefix '{pfx}' on attribute"), + ); + } else { + return Err(self + .input + .fatal(format!("unbound namespace prefix '{pfx}' on attribute"))); + } + } + attr.namespace = resolved; + } + } + } + + // Namespace-aware attribute uniqueness: two attributes with the same + // namespace URI and local name are duplicates, even if they use different + // prefixes (Namespaces in XML 1.0 §6.3). + // Only meaningful when there are 2+ namespaced (non-xmlns) attributes. + // Skip entirely when no prefixed attributes exist (no namespaces were + // resolved, so ns_attr_count is guaranteed to be 0). + if has_prefixed_attrs { + let ns_attr_count = attributes.iter().filter(|a| a.namespace.is_some()).count(); + if ns_attr_count >= 2 { + // O(n²) comparison avoids HashSet allocation + 'ns_outer: for i in 1..attributes.len() { + if attributes[i].namespace.is_none() { + continue; + } + for j in 0..i { + if attributes[j].namespace.is_none() { + continue; + } + if attributes[i].namespace == attributes[j].namespace + && attributes[i].name == attributes[j].name + { + let display = if let Some(ns) = &attributes[i].namespace { + format!("{{{}}}:{}", ns, attributes[i].name) + } else { + attributes[i].name.clone() + }; + if self.options.recover { + self.input.push_diagnostic( + ErrorSeverity::Error, + format!("namespace-aware duplicate attribute: '{display}'"), + ); + } else { + return Err(self.input.fatal(format!( + "namespace-aware duplicate attribute: '{display}'" + ))); + } + break 'ns_outer; + } + } + } + } + } + + // Remove stripped attributes (e.g., invalid xmlns:xml rebindings). + if !strip_attr_indices.is_empty() { + // Remove in reverse order to preserve indices. + for &idx in strip_attr_indices.iter().rev() { + attributes.remove(idx); + } + } + + // Consume the original name String via split_owned_name, avoiding a + // re-allocation for unprefixed names (the common case). For unprefixed + // names, split_owned_name returns (None, name) — just a move, zero copy. + let (elem_prefix_owned, elem_local_owned) = split_owned_name(name); + // Auto-populate id_map for "id" attributes (enables element_by_id + // and fast CSS #id selectors without requiring DTD validation). + let id_value = attributes.iter().find_map(|a| { + if a.prefix.is_none() && a.name == "id" { + Some(a.value.clone()) + } else { + None + } + }); + + let elem_id = self.doc.create_node(NodeKind::Element { + name: elem_local_owned, + prefix: elem_prefix_owned, + namespace: elem_ns, + attributes, + }); + self.doc.append_child(parent, elem_id); + + if let Some(id_val) = id_value { + self.doc.set_id(&id_val, elem_id); + } + + // Empty element tag <foo/> + if self.input.looking_at(b"/>") { + self.input.advance(2); + if has_ns_decls { + self.ns.pop_scope(); + } + self.input.decrement_depth(); + return Ok(elem_id); + } + + // Start tag close > + self.input.expect_byte(b'>')?; + + // Parse element content + self.parse_content(elem_id)?; + + // Parse end tag — read back the stored name from the tree node + // for matching, since the original name was consumed by split_owned_name. + self.input.expect_str(b"</")?; + let (match_prefix, match_local) = { + let node = self.doc.node(elem_id); + match &node.kind { + NodeKind::Element { name, prefix, .. } => (prefix.as_deref(), name.as_str()), + _ => unreachable!(), + } + }; + if let Some(end_name) = self.input.parse_name_eq_parts(match_prefix, match_local)? { + let expected = match match_prefix { + Some(pfx) => format!("{pfx}:{match_local}"), + None => match_local.to_string(), + }; + if self.options.recover { + self.input.push_diagnostic( + ErrorSeverity::Error, + format!("mismatched end tag: expected </{expected}>, found </{end_name}>"), + ); + } else { + return Err(self.input.fatal(format!( + "mismatched end tag: expected </{expected}>, found </{end_name}>" + ))); + } + } + self.input.skip_whitespace(); + self.input.expect_byte(b'>')?; + + // Pop the namespace scope when leaving this element (only if we pushed). + if has_ns_decls { + self.ns.pop_scope(); + } + self.input.decrement_depth(); + + Ok(elem_id) + } + + /// Normalizes an attribute value based on its DTD-declared type. + /// + /// For non-CDATA types (e.g., NMTOKEN, ID, IDREF), collapses whitespace: + /// trim leading/trailing whitespace, reduce internal whitespace sequences + /// to single spaces (XML 1.0 §3.3.3). + fn normalize_attr_value_by_type( + &self, + element_name: &str, + attr_name: &str, + value: &str, + ) -> String { + // Fast path: skip lookup when no DTD attribute types are declared + // (the common case for documents without a DTD). + if !self.attr_types.is_empty() { + let key = (element_name.to_string(), attr_name.to_string()); + if let Some(attr_type) = self.attr_types.get(&key) { + if !matches!(attr_type, AttributeType::CData) { + return value.split_whitespace().collect::<Vec<_>>().join(" "); + } + } + } + value.to_string() + } + + // --- Content --- + // See XML 1.0 §3.1: [43] content + + fn parse_content(&mut self, parent: NodeId) -> Result<(), ParseError> { + loop { + if self.input.at_end() { + if self.options.recover { + break; + } + return Err(self + .input + .fatal("unexpected end of input in element content")); + } + + // End tag starts + if self.input.looking_at(b"</") { + break; + } + + if self.input.looking_at(b"<![CDATA[") { + self.parse_cdata(parent)?; + } else if self.input.looking_at(b"<!--") { + self.parse_comment(parent)?; + } else if self.input.looking_at(b"<?") { + self.parse_processing_instruction(parent)?; + } else if self.input.peek() == Some(b'<') { + self.parse_element(parent)?; + } else { + self.parse_char_data(parent)?; + } + } + Ok(()) + } + + // --- Character Data --- + // See XML 1.0 §2.4: [14] CharData + + #[allow(clippy::too_many_lines)] + fn parse_char_data(&mut self, parent: NodeId) -> Result<(), ParseError> { + let mut text = String::new(); + + while !self.input.at_end() { + // Bulk scan: find the next `<`, `&`, or `]]>` boundary and + // consume all safe bytes in one go. + let safe_len = self.input.scan_char_data(); + if safe_len > 0 { + let start = self.input.pos(); + let chunk = std::str::from_utf8(self.input.slice(start, start + safe_len)) + .map_err(|_| self.input.fatal("invalid UTF-8 in character data"))?; + // Fast byte-level pre-check for invalid XML chars (0x7F, + // U+FFFE, U+FFFF). Skips the expensive char-by-char + // validation for the 99.9% of chunks that are clean. + let bad_char = if may_contain_invalid_xml_chars(chunk.as_bytes()) { + find_invalid_xml_char(chunk) + } else { + None + }; + // Append text with CR normalization if needed (XML 1.0 §2.11) + if chunk.as_bytes().contains(&b'\r') { + let mut chars = chunk.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\r' { + if chars.peek() == Some(&'\n') { + chars.next(); + } + text.push('\n'); + } else { + text.push(ch); + } + } + } else { + text.push_str(chunk); + } + // chunk borrow released — safe to mutably borrow self.input + self.input.advance_counting_lines(safe_len); + if let Some(bad) = bad_char { + if self.options.recover { + self.input.push_diagnostic( + ErrorSeverity::Error, + format!("invalid XML character: U+{:04X}", bad as u32), + ); + } else { + return Err(self + .input + .fatal(format!("invalid XML character: U+{:04X}", bad as u32))); + } + } + continue; + } + + if self.input.peek() == Some(b'<') { + break; + } + + // XML 1.0 §2.4: "]]>" is forbidden in character data + if self.input.looking_at(b"]]>") { + if self.options.recover { + self.input.push_diagnostic( + ErrorSeverity::Error, + "']]>' not allowed in character data".to_string(), + ); + text.push_str("]]>"); + self.input.advance(3); + continue; + } + return Err(self.input.fatal("']]>' not allowed in character data")); + } + + if self.input.peek() == Some(b'&') { + // A named general entity reference in content (not a char ref, + // not a builtin) is included per XML 1.0 §4.4: an EntityRef + // node is created and the entity's replacement text is parsed + // as content, attached as children of the EntityRef node. + // Skip the peek for builtins at byte level to avoid String allocation. + if self.input.peek_at(1) != Some(b'#') + && !self.is_looking_at_builtin_entity_ref() + && self.peek_entity_ref_name().is_some() + { + // Flush accumulated text before the entity ref + if !text.is_empty() { + let text_id = self.doc.create_node(NodeKind::Text { + content: std::mem::take(&mut text), + }); + self.doc.append_child(parent, text_id); + } + self.parse_entity_ref_in_content(parent)?; + continue; + } + self.input.parse_reference_into(&mut text)?; + } else { + let ch = self.input.next_char()?; + text.push(ch); + } + } + + if !text.is_empty() { + // Strip blank text nodes if configured + if self.options.no_blanks && text.chars().all(char::is_whitespace) { + return Ok(()); + } + let text_id = self.doc.create_node(NodeKind::Text { content: text }); + self.doc.append_child(parent, text_id); + } + + Ok(()) + } + + /// Parses a general entity reference (`&name;`) appearing in element + /// content per XML 1.0 §4.4 ("Included"). + /// + /// Creates an `EntityRef` node under `parent`, then parses the entity's + /// replacement text (XML 1.0 §4.5: character references expanded at + /// declaration time) as content, attaching the resulting nodes as + /// children of the `EntityRef` node. + fn parse_entity_ref_in_content(&mut self, parent: NodeId) -> Result<(), ParseError> { + // Consume `&name;` + self.input.advance(1); // '&' + let name = self.input.parse_name()?; + self.input.expect_byte(b';')?; + + // Count this reference against the expansion limit. + self.input.count_entity_expansion()?; + + // Resolve the replacement text (XML 1.0 §4.5). + let replacement: Option<String> = if let Some(raw) = self.input.entity_map.get(&name) { + Some(crate::validation::dtd::expand_char_refs_only(raw)) + } else if let Some(info) = self.input.entity_external.get(&name).cloned() { + if let Some(resolver) = self.options.entity_resolver.clone() { + let request = crate::parser::ExternalEntityRequest { + name: &name, + system_id: &info.system_id, + public_id: info.public_id.as_deref(), + }; + if let Some(resolved) = resolver(request) { + // External parsed entities may begin with a text + // declaration (XML 1.0 §4.3.1) which is not content. + Some(strip_text_declaration(&resolved).to_string()) + } else { + return Err(self.input.fatal(format!( + "reference to external entity '{name}' is not supported" + ))); + } + } else { + return Err(self.input.fatal(format!( + "reference to external entity '{name}' is not supported" + ))); + } + } else if self.input.has_pe_references || self.input.has_external_dtd { + // Undeclared entity in tolerant mode (external DTD or PE refs + // present, XML 1.0 §4.1 WFC: Entity Declared): preserve the + // reference without a value. + None + } else if self.options.recover { + self.input.push_diagnostic( + ErrorSeverity::Warning, + format!("unknown entity reference: &{name};"), + ); + None + } else { + return Err(self + .input + .fatal(format!("unknown entity reference: &{name};"))); + }; + + let ref_id = self.doc.create_node(NodeKind::EntityRef { + name: name.clone(), + value: replacement.clone(), + }); + self.doc.append_child(parent, ref_id); + + let Some(replacement) = replacement else { + return Ok(()); + }; + if replacement.is_empty() { + return Ok(()); + } + + // Amplification guard (matches libxml2's max amplification factor). + self.expansion_size += replacement.len(); + if self.expansion_size > self.input_size.saturating_mul(DEFAULT_MAX_AMPLIFICATION) { + return Err(self + .input + .fatal("maximum entity amplification factor exceeded")); + } + + if !replacement.contains('<') && !replacement.contains('&') { + // Fast path: plain character data — no markup, no references. + let content = if replacement.contains('\r') { + normalize_line_ends(&replacement) + } else { + replacement + }; + let text_id = self.doc.create_node(NodeKind::Text { content }); + self.doc.append_child(ref_id, text_id); + } else { + self.parse_replacement_as_content(&name, &replacement, ref_id)?; + } + Ok(()) + } + + /// Parses an entity's replacement text as XML content (XML 1.0 §4.3.2: + /// the replacement text must match the `content` production), attaching + /// the parsed nodes as children of `ref_id`. + /// + /// A nested sub-parser is used so the replacement text is processed with + /// the same entity declarations, namespace scope, options, and security + /// counters as the outer document. + fn parse_replacement_as_content( + &mut self, + entity_name: &str, + replacement: &str, + ref_id: NodeId, + ) -> Result<(), ParseError> { + if self.entity_depth >= MAX_ENTITY_DEPTH { + return Err(self.input.fatal(format!( + "entity '{entity_name}' exceeds maximum entity nesting depth" + ))); + } + + let mut sub = XmlParser::new(replacement, &self.options); + sub.entity_depth = self.entity_depth + 1; + sub.input_size = self.input_size; + sub.expansion_size = self.expansion_size; + // Inherit the element nesting depth so total depth across entity + // expansions stays bounded by max_depth (a fresh counter per + // sub-parser would allow MAX_ENTITY_DEPTH * max_depth stack frames — + // enough to overflow the stack). + sub.input.set_depth(self.input.depth()); + // Move shared state into the sub-parser (returned below). + sub.input.entity_map = std::mem::take(&mut self.input.entity_map); + sub.input.entity_external = std::mem::take(&mut self.input.entity_external); + sub.input.has_pe_references = self.input.has_pe_references; + sub.input.has_external_dtd = self.input.has_external_dtd; + sub.input.entity_expansions = self.input.entity_expansions; + sub.attr_types = std::mem::take(&mut self.attr_types); + sub.attr_defaults = std::mem::take(&mut self.attr_defaults); + std::mem::swap(&mut sub.ns, &mut self.ns); + + let sub_root = sub.doc.root(); + let result = sub.parse_content_fragment(sub_root); + + // Restore shared state regardless of outcome. + self.input.entity_map = std::mem::take(&mut sub.input.entity_map); + self.input.entity_external = std::mem::take(&mut sub.input.entity_external); + self.input.entity_expansions = sub.input.entity_expansions; + self.attr_types = std::mem::take(&mut sub.attr_types); + self.attr_defaults = std::mem::take(&mut sub.attr_defaults); + std::mem::swap(&mut self.ns, &mut sub.ns); + self.expansion_size = sub.expansion_size; + if !sub.input.diagnostics.is_empty() { + self.input.diagnostics.append(&mut sub.input.diagnostics); + } + + if let Err(e) = result { + // Propagate security-limit errors and already-wrapped entity + // errors unchanged to avoid nested message wrapping. + if e.message.contains("exceeded") + || e.message.contains("replacement text is not well-formed") + { + return Err(e); + } + return Err(self.input.fatal(format!( + "entity '{entity_name}' replacement text is not well-formed \ + XML content: {}", + e.message + ))); + } + + // Graft the parsed nodes into our arena under the EntityRef node. + let sub_doc = sub.doc; + let top_level: Vec<NodeId> = sub_doc.children(sub_doc.root()).collect(); + for child in top_level { + Self::import_subtree(&mut self.doc, &sub_doc, child, ref_id); + } + Ok(()) + } + + /// Parses content until end of input (used for entity replacement text, + /// which must match the `content` production — XML 1.0 §4.3.2). + /// + /// Unlike [`parse_content`](Self::parse_content), end of input is the + /// normal termination and an end tag (`</`) is an error (it would close + /// an element opened outside the entity — WFC violation). + fn parse_content_fragment(&mut self, parent: NodeId) -> Result<(), ParseError> { + while !self.input.at_end() { + if self.input.looking_at(b"</") { + return Err(self.input.fatal("unbalanced end tag")); + } + if self.input.looking_at(b"<![CDATA[") { + self.parse_cdata(parent)?; + } else if self.input.looking_at(b"<!--") { + self.parse_comment(parent)?; + } else if self.input.looking_at(b"<?") { + self.parse_processing_instruction(parent)?; + } else if self.input.peek() == Some(b'<') { + self.parse_element(parent)?; + } else { + self.parse_char_data(parent)?; + } + } + Ok(()) + } + + /// Recursively deep-copies a subtree from `src` into `dst`, appending + /// the copy under `dst_parent`. + fn import_subtree(dst: &mut Document, src: &Document, src_id: NodeId, dst_parent: NodeId) { + let kind = src.node(src_id).kind.clone(); + let new_id = dst.create_node(kind); + dst.append_child(dst_parent, new_id); + for child in src.children(src_id) { + Self::import_subtree(dst, src, child, new_id); + } + } + + /// Checks if the input is positioned at a builtin entity reference + /// (`&amp;`, `&lt;`, `&gt;`, `&apos;`, `&quot;`) using byte-level + /// checks. This avoids the `String` allocation of `peek_entity_ref_name`. + #[inline] + fn is_looking_at_builtin_entity_ref(&self) -> bool { + let remaining = self.input.remaining(); + if remaining.len() < 4 || remaining[0] != b'&' { + return false; + } + let after_amp = &remaining[1..]; + after_amp.starts_with(b"lt;") + || after_amp.starts_with(b"gt;") + || after_amp.starts_with(b"amp;") + || after_amp.starts_with(b"apos;") + || after_amp.starts_with(b"quot;") + } + + /// Peeks ahead to extract the entity name from `&name;` without consuming + /// any input. Returns `None` if the next bytes don't form a valid entity + /// reference pattern. + fn peek_entity_ref_name(&self) -> Option<String> { + // We're at `&` — look ahead past it to find the name and `;` + let remaining = self.input.remaining(); + if remaining.len() < 2 || remaining[0] != b'&' { + return None; + } + let mut i = 1; + // Collect name bytes + let name_start = i; + while i < remaining.len() + && (remaining[i].is_ascii_alphanumeric() + || remaining[i] == b'_' + || remaining[i] == b':' + || remaining[i] == b'-' + || remaining[i] == b'.') + { + i += 1; + } + if i == name_start || i >= remaining.len() || remaining[i] != b';' { + return None; + } + std::str::from_utf8(&remaining[name_start..i]) + .ok() + .map(String::from) + } + + /// Checks if all entity references (`&name;`) in a raw attribute value + /// text are declared in the entity map. Returns false if any undeclared + /// entity ref is found (these should not be preserved via `raw_value`). + fn all_entity_refs_declared(&self, raw: &str) -> bool { + let bytes = raw.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'&' && i + 1 < bytes.len() && bytes[i + 1] != b'#' { + // Named entity reference — extract the name + let mut j = i + 1; + while j < bytes.len() + && (bytes[j].is_ascii_alphanumeric() + || bytes[j] == b'_' + || bytes[j] == b':' + || bytes[j] == b'-' + || bytes[j] == b'.') + { + j += 1; + } + if j < bytes.len() && bytes[j] == b';' { + let name = std::str::from_utf8(&bytes[i + 1..j]).unwrap_or(""); + if !is_builtin_entity(name) && !self.input.entity_map.contains_key(name) { + return false; + } + i = j + 1; + } else { + i += 1; + } + } else { + i += 1; + } + } + true + } + + // --- Attributes --- + // See XML 1.0 §3.1: [41] Attribute + + fn parse_attribute(&mut self) -> Result<Attribute, ParseError> { + let name = self.input.parse_name()?; + self.input.skip_whitespace(); + self.input.expect_byte(b'=')?; + self.input.skip_whitespace(); + + // Capture raw attribute value text (before entity expansion) so we + // can preserve entity references during serialization. + let raw_start = self.input.pos(); + let value = self.input.parse_attribute_value()?; + let raw_end = self.input.pos(); + + // Extract raw value (between quotes) — the raw slice includes the + // outer quote chars, so trim them. Skip entirely when the raw bytes + // contain no '&' (most attributes have no entity references). + let raw_value = if raw_end > raw_start + 2 { + let raw_bytes = self.input.slice(raw_start + 1, raw_end - 1); + if raw_bytes.contains(&b'&') { + let raw_str = std::str::from_utf8(raw_bytes).ok().map(str::to_string); + // Only store raw_value if it differs from the expanded value + // (i.e., it contained entity references that got expanded) AND + // all entity references in the raw value are declared (not + // undeclared entities that expanded to empty string). + raw_str.filter(|raw| *raw != value && self.all_entity_refs_declared(raw)) + } else { + None + } + } else { + None + }; + + let (prefix, local_name) = split_owned_name(name); + + Ok(Attribute { + name: local_name, + value, + prefix, + namespace: None, + raw_value, + }) + } + + // --- Comments --- + // See XML 1.0 §2.5: [15] Comment + + fn parse_comment(&mut self, parent: NodeId) -> Result<(), ParseError> { + let content = parse_comment_content(&mut self.input)?; + let comment_id = self.doc.create_node(NodeKind::Comment { content }); + self.doc.append_child(parent, comment_id); + Ok(()) + } + + // --- CDATA Sections --- + // See XML 1.0 §2.7: [18] CDSect + + fn parse_cdata(&mut self, parent: NodeId) -> Result<(), ParseError> { + let content = parse_cdata_content(&mut self.input)?; + let cdata_id = self.doc.create_node(NodeKind::CData { content }); + self.doc.append_child(parent, cdata_id); + Ok(()) + } + + // --- Processing Instructions --- + // See XML 1.0 §2.6: [16] PI + + fn parse_processing_instruction(&mut self, parent: NodeId) -> Result<(), ParseError> { + let (target, data) = parse_pi_content(&mut self.input)?; + let pi_id = self + .doc + .create_node(NodeKind::ProcessingInstruction { target, data }); + self.doc.append_child(parent, pi_id); + Ok(()) + } +} + +/// Normalizes line ends per XML 1.0 §2.11: `\r\n` and lone `\r` become `\n`. +fn normalize_line_ends(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\r' { + if chars.peek() == Some(&'\n') { + chars.next(); + } + out.push('\n'); + } else { + out.push(ch); + } + } + out +} + +/// Strips an optional leading text declaration (`<?xml ...?>`) from external +/// parsed entity text (XML 1.0 §4.3.1: `TextDecl`). +fn strip_text_declaration(s: &str) -> &str { + let rest = s.strip_prefix("<?xml").filter(|r| { + r.starts_with(' ') || r.starts_with('\t') || r.starts_with('\r') || r.starts_with('\n') + }); + if let Some(rest) = rest { + if let Some(end) = rest.find("?>") { + return &rest[end + 2..]; + } + } + s +} + +/// Returns true if the entity name is one of the five XML builtin entities. +fn is_builtin_entity(name: &str) -> bool { + matches!(name, "amp" | "lt" | "gt" | "apos" | "quot") +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::tree::Document; + + fn parse(input: &str) -> Document { + Document::parse_str(input).unwrap_or_else(|e| panic!("parse failed: {e}")) + } + + #[test] + fn test_parse_empty_element() { + let doc = parse("<root/>"); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_name(root), Some("root")); + assert_eq!(doc.first_child(root), None); + } + + #[test] + fn test_parse_element_with_text() { + let doc = parse("<greeting>Hello, world!</greeting>"); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_name(root), Some("greeting")); + assert_eq!(doc.text_content(root), "Hello, world!"); + } + + #[test] + fn test_parse_nested_elements() { + let doc = parse("<a><b><c/></b></a>"); + let a = doc.root_element().unwrap(); + assert_eq!(doc.node_name(a), Some("a")); + + let b = doc.first_child(a).unwrap(); + assert_eq!(doc.node_name(b), Some("b")); + + let c = doc.first_child(b).unwrap(); + assert_eq!(doc.node_name(c), Some("c")); + } + + #[test] + fn test_parse_attributes() { + let doc = parse("<div id=\"main\" class=\"big\"/>"); + let root = doc.root_element().unwrap(); + assert_eq!(doc.attribute(root, "id"), Some("main")); + assert_eq!(doc.attribute(root, "class"), Some("big")); + } + + #[test] + fn test_parse_single_quoted_attributes() { + let doc = parse("<div id='main'/>"); + let root = doc.root_element().unwrap(); + assert_eq!(doc.attribute(root, "id"), Some("main")); + } + + #[test] + fn test_parse_xml_declaration() { + let doc = parse("<?xml version=\"1.0\" encoding=\"UTF-8\"?><root/>"); + assert_eq!(doc.version.as_deref(), Some("1.0")); + assert_eq!(doc.encoding.as_deref(), Some("UTF-8")); + } + + #[test] + fn test_parse_xml_declaration_standalone() { + let doc = parse("<?xml version=\"1.0\" standalone=\"yes\"?><root/>"); + assert_eq!(doc.standalone, Some(true)); + } + + #[test] + fn test_parse_comment() { + let doc = parse("<root><!-- hello --></root>"); + let root = doc.root_element().unwrap(); + let child = doc.first_child(root).unwrap(); + assert_eq!(doc.node_text(child), Some(" hello ")); + } + + #[test] + fn test_parse_cdata() { + let doc = parse("<root><![CDATA[x < 1 && y > 2]]></root>"); + let root = doc.root_element().unwrap(); + let child = doc.first_child(root).unwrap(); + assert_eq!(doc.node_text(child), Some("x < 1 && y > 2")); + } + + #[test] + fn test_parse_processing_instruction() { + let doc = parse("<?my-pi some data?><root/>"); + let pi = doc.first_child(doc.root()).unwrap(); + assert_eq!(doc.node_name(pi), Some("my-pi")); + assert_eq!(doc.node_text(pi), Some("some data")); + } + + #[test] + fn test_parse_entity_references() { + let doc = parse("<root>&amp; &lt; &gt; &apos; &quot;</root>"); + let root = doc.root_element().unwrap(); + assert_eq!(doc.text_content(root), "& < > ' \""); + } + + #[test] + fn test_parse_char_reference_decimal() { + let doc = parse("<root>&#65;</root>"); + let root = doc.root_element().unwrap(); + assert_eq!(doc.text_content(root), "A"); + } + + #[test] + fn test_parse_char_reference_hex() { + let doc = parse("<root>&#x41;</root>"); + let root = doc.root_element().unwrap(); + assert_eq!(doc.text_content(root), "A"); + } + + #[test] + fn test_parse_mixed_content() { + let doc = parse("<p>Hello <b>world</b>!</p>"); + let p = doc.root_element().unwrap(); + let children: Vec<_> = doc.children(p).collect(); + assert_eq!(children.len(), 3); // "Hello ", <b>, "!" + + assert_eq!(doc.node_text(children[0]), Some("Hello ")); + assert_eq!(doc.node_name(children[1]), Some("b")); + assert_eq!(doc.text_content(children[1]), "world"); + assert_eq!(doc.node_text(children[2]), Some("!")); + } + + #[test] + fn test_parse_prefixed_element() { + let doc = parse("<svg:rect xmlns:svg=\"http://www.w3.org/2000/svg\"/>"); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_name(root), Some("rect")); + match &doc.node(root).kind { + NodeKind::Element { prefix, .. } => { + assert_eq!(prefix.as_deref(), Some("svg")); + } + _ => panic!("expected element"), + } + } + + #[test] + fn test_parse_prefixed_attribute() { + let doc = parse("<root xml:lang=\"en\"/>"); + let root = doc.root_element().unwrap(); + let attrs = doc.attributes(root); + assert_eq!(attrs.len(), 1); + assert_eq!(attrs[0].name, "lang"); + assert_eq!(attrs[0].prefix.as_deref(), Some("xml")); + assert_eq!(attrs[0].value, "en"); + } + + #[test] + fn test_parse_error_mismatched_tags() { + let result = Document::parse_str("<a></b>"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_error_unexpected_eof() { + let result = Document::parse_str("<a>"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_error_no_root() { + let result = Document::parse_str(""); + // XML 1.0 §2.1 requires a root element + assert!(result.is_err()); + } + + /// The XML declaration prefix that `serialize()` always emits. + const DECL: &str = "<?xml version=\"1.0\"?>\n"; + + #[test] + fn test_roundtrip_simple() { + let input = "<root><child>text</child></root>"; + let doc = parse(input); + let output = crate::serial::serialize(&doc); + assert_eq!(output, format!("{DECL}{input}\n")); + } + + #[test] + fn test_roundtrip_attributes() { + let input = "<root attr=\"value\"><child id=\"1\"/></root>"; + let doc = parse(input); + let output = crate::serial::serialize(&doc); + assert_eq!(output, format!("{DECL}{input}\n")); + } + + #[test] + fn test_roundtrip_entities() { + let input = "<root>&amp; &lt; &gt;</root>"; + let doc = parse(input); + let output = crate::serial::serialize(&doc); + // After parsing, entities are resolved to characters. + // Serialization re-escapes them. + assert_eq!(output, format!("{DECL}<root>&amp; &lt; &gt;</root>\n")); + } + + #[test] + fn test_roundtrip_comment() { + let input = "<root><!-- comment --></root>"; + let doc = parse(input); + let output = crate::serial::serialize(&doc); + assert_eq!(output, format!("{DECL}{input}\n")); + } + + #[test] + fn test_roundtrip_cdata() { + let input = "<root><![CDATA[data & stuff]]></root>"; + let doc = parse(input); + let output = crate::serial::serialize(&doc); + assert_eq!(output, format!("{DECL}{input}\n")); + } + + #[test] + fn test_roundtrip_pi() { + let input = "<?target data?><root/>"; + let doc = parse(input); + let output = crate::serial::serialize(&doc); + assert_eq!(output, format!("{DECL}{input}\n")); + } + + #[test] + fn test_roundtrip_xml_declaration() { + let input = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><root/>"; + let doc = parse(input); + let output = crate::serial::serialize(&doc); + assert_eq!( + output, + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root/>\n" + ); + } + + #[test] + fn test_whitespace_in_attribute_value() { + let doc = parse("<root attr=\"a\tb\nc\"/>"); + let root = doc.root_element().unwrap(); + // Tabs and newlines in attribute values are normalized to spaces + assert_eq!(doc.attribute(root, "attr"), Some("a b c")); + } + + #[test] + fn test_name_chars() { + use super::super::input::{is_name_char, is_name_start_char}; + + assert!(is_name_start_char('A')); + assert!(is_name_start_char('z')); + assert!(is_name_start_char('_')); + assert!(is_name_start_char(':')); + assert!(!is_name_start_char('0')); + assert!(!is_name_start_char('-')); + + assert!(is_name_char('A')); + assert!(is_name_char('0')); + assert!(is_name_char('-')); + assert!(is_name_char('.')); + assert!(!is_name_char(' ')); + } + + #[test] + fn test_parse_doctype_simple() { + let doc = parse("<!DOCTYPE html><html/>"); + let root = doc.root(); + let children: Vec<_> = doc.children(root).collect(); + assert_eq!(children.len(), 2); + + match &doc.node(children[0]).kind { + NodeKind::DocumentType { + name, + system_id, + public_id, + .. + } => { + assert_eq!(name, "html"); + assert_eq!(*system_id, None); + assert_eq!(*public_id, None); + } + other => panic!("expected DocumentType, got {other:?}"), + } + + assert_eq!(doc.node_name(children[1]), Some("html")); + } + + #[test] + fn test_parse_doctype_system() { + let doc = parse("<!DOCTYPE root SYSTEM \"root.dtd\"><root/>"); + let root = doc.root(); + let children: Vec<_> = doc.children(root).collect(); + assert_eq!(children.len(), 2); + + match &doc.node(children[0]).kind { + NodeKind::DocumentType { + name, + system_id, + public_id, + .. + } => { + assert_eq!(name, "root"); + assert_eq!(system_id.as_deref(), Some("root.dtd")); + assert_eq!(*public_id, None); + } + other => panic!("expected DocumentType, got {other:?}"), + } + } + + #[test] + fn test_parse_doctype_public() { + let doc = parse( + "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0//EN\" \ + \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\"><html/>", + ); + let root = doc.root(); + let children: Vec<_> = doc.children(root).collect(); + assert_eq!(children.len(), 2); + + match &doc.node(children[0]).kind { + NodeKind::DocumentType { + name, + system_id, + public_id, + .. + } => { + assert_eq!(name, "html"); + assert_eq!(public_id.as_deref(), Some("-//W3C//DTD XHTML 1.0//EN")); + assert_eq!( + system_id.as_deref(), + Some("http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd") + ); + } + other => panic!("expected DocumentType, got {other:?}"), + } + } + + #[test] + fn test_parse_doctype_internal_subset() { + let doc = parse("<!DOCTYPE root [<!ELEMENT root (#PCDATA)>]><root/>"); + let root = doc.root(); + let children: Vec<_> = doc.children(root).collect(); + assert_eq!(children.len(), 2); + + match &doc.node(children[0]).kind { + NodeKind::DocumentType { + name, + system_id, + public_id, + .. + } => { + assert_eq!(name, "root"); + assert_eq!(*system_id, None); + assert_eq!(*public_id, None); + } + other => panic!("expected DocumentType, got {other:?}"), + } + + assert_eq!(doc.node_name(children[1]), Some("root")); + } + + #[test] + fn test_parse_doctype_multiline_internal_subset() { + let input = + "<!DOCTYPE root [\n<!ELEMENT y (#PCDATA|x|x)*>\n<!ELEMENT root ANY>\n]>\n\n<root/>"; + let doc = parse(input); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_name(root), Some("root")); + } + + #[test] + fn test_parse_doctype_with_entity() { + let input = "<!DOCTYPE doc [\n<!ELEMENT doc (#PCDATA)>\n<!ENTITY rsqb \"]\">\n]>\n<doc>&rsqb;</doc>"; + let doc = parse(input); + let root = doc.root_element().unwrap(); + assert_eq!(doc.text_content(root), "]"); + } + + #[test] + fn test_parse_doctype_content_model() { + let input = "<!DOCTYPE violation [\n<!ELEMENT violation (a,a,a,b)>\n<!ELEMENT a EMPTY>\n<!ELEMENT b EMPTY>\n]>\n<violation>\n <a/>\n <a/>\n <b/>\n</violation>"; + let doc = parse(input); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_name(root), Some("violation")); + } + + #[test] + fn test_parse_doctype_with_crlf() { + // Test with CRLF line endings (like the OASIS conformance tests) + let input = + "<!DOCTYPE doc\r\n[\r\n<!ELEMENT doc ANY>\r\n<!ELEMENT a (doc?)>\r\n]>\r\n<doc/>"; + let doc = parse(input); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_name(root), Some("doc")); + } + + #[test] + fn test_parse_doctype_attlist() { + let input = "<!DOCTYPE root [\n<!ELEMENT root EMPTY>\n<!ATTLIST root\n token\tNMTOKEN\t\t#REQUIRED\n >\n\n <!-- comment -->\n]>\n<root token=\"dev@null\"/>"; + let doc = parse(input); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_name(root), Some("root")); + } + + #[test] + fn test_parse_doctype_comment_with_apostrophe() { + // Apostrophes in DTD comments must not confuse the bracket scanner + let input = "<!DOCTYPE root [\n<!ELEMENT root ANY>\n<!-- can't break -->\n]>\n<root/>"; + let doc = parse(input); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_name(root), Some("root")); + } + + #[test] + fn test_roundtrip_doctype() { + // Simple DOCTYPE (no whitespace between DOCTYPE and root in input) + let input = "<!DOCTYPE html><html/>"; + let doc = parse(input); + let output = crate::serial::serialize(&doc); + assert_eq!(output, format!("{DECL}<!DOCTYPE html><html/>\n")); + + // DOCTYPE with SYSTEM + let input = "<!DOCTYPE root SYSTEM \"root.dtd\"><root/>"; + let doc = parse(input); + let output = crate::serial::serialize(&doc); + assert_eq!( + output, + format!("{DECL}<!DOCTYPE root SYSTEM \"root.dtd\"><root/>\n") + ); + + // DOCTYPE with PUBLIC + let input = "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0//EN\" \ + \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\"><html/>"; + let doc = parse(input); + let output = crate::serial::serialize(&doc); + assert_eq!( + output, + format!( + "{DECL}<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0//EN\" \ + \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\"><html/>\n" + ) + ); + } + + // --- Namespace resolution tests --- + + #[test] + fn test_parse_default_namespace() { + let doc = parse("<root xmlns=\"http://example.com\"/>"); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_namespace(root), Some("http://example.com")); + } + + #[test] + fn test_parse_prefixed_namespace() { + let doc = parse("<ns:root xmlns:ns=\"http://example.com\"/>"); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_name(root), Some("root")); + assert_eq!(doc.node_namespace(root), Some("http://example.com")); + match &doc.node(root).kind { + NodeKind::Element { prefix, .. } => { + assert_eq!(prefix.as_deref(), Some("ns")); + } + _ => panic!("expected element"), + } + } + + #[test] + fn test_parse_nested_namespace() { + // Child elements inherit the default namespace from the parent. + let doc = parse("<root xmlns=\"http://example.com\"><child/></root>"); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_namespace(root), Some("http://example.com")); + + let child = doc.first_child(root).unwrap(); + assert_eq!(doc.node_name(child), Some("child")); + assert_eq!(doc.node_namespace(child), Some("http://example.com")); + } + + #[test] + fn test_parse_namespace_override() { + // A child element can override the parent's default namespace. + let doc = parse( + "<root xmlns=\"http://example.com\">\ + <child xmlns=\"http://other.com\"/>\ + </root>", + ); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_namespace(root), Some("http://example.com")); + + let child = doc.first_child(root).unwrap(); + assert_eq!(doc.node_namespace(child), Some("http://other.com")); + } + + #[test] + fn test_parse_xml_namespace() { + // The xml: prefix is always bound to the XML namespace URI. + let doc = parse("<root xml:lang=\"en\"/>"); + let root = doc.root_element().unwrap(); + let attrs = doc.attributes(root); + assert_eq!(attrs.len(), 1); + assert_eq!(attrs[0].name, "lang"); + assert_eq!(attrs[0].prefix.as_deref(), Some("xml")); + assert_eq!( + attrs[0].namespace.as_deref(), + Some("http://www.w3.org/XML/1998/namespace") + ); + } + + #[test] + fn test_parse_attribute_namespace() { + // Prefixed attributes get their namespace resolved. + let doc = parse("<root xmlns:app=\"http://example.com/app\" app:version=\"2.0\"/>"); + let root = doc.root_element().unwrap(); + let attrs = doc.attributes(root); + + // Find the app:version attribute + let version_attr = attrs.iter().find(|a| a.name == "version").unwrap(); + assert_eq!(version_attr.prefix.as_deref(), Some("app")); + assert_eq!( + version_attr.namespace.as_deref(), + Some("http://example.com/app") + ); + + // The xmlns:app attribute should not have a resolved namespace itself. + let xmlns_attr = attrs.iter().find(|a| a.name == "app").unwrap(); + assert_eq!(xmlns_attr.prefix.as_deref(), Some("xmlns")); + assert_eq!(xmlns_attr.namespace, None); + } + + // -- General entity inclusion in content (issue #43) -------------------- + // + // XML 1.0 §4.4 "Included": a general entity referenced in content has + // its replacement text parsed as content. §4.5: character references in + // the declaration are expanded when the replacement text is built. + + #[test] + fn test_parse_entity_charref_replacement() { + let doc = parse("<!DOCTYPE d [<!ENTITY e \"caf&#233;\">]><d>&e;</d>"); + let root = doc.root_element().unwrap(); + assert_eq!(doc.text_content(root), "caf\u{e9}"); + } + + #[test] + fn test_parse_entity_nested_reference() { + let doc = parse("<!DOCTYPE d [<!ENTITY a \"XYZ\"><!ENTITY b \"&a;\">]><d>&b;</d>"); + let root = doc.root_element().unwrap(); + assert_eq!(doc.text_content(root), "XYZ"); + // Tree shape: EntityRef(b) -> EntityRef(a) -> Text("XYZ"). + let outer = doc.first_child(root).unwrap(); + let NodeKind::EntityRef { ref name, .. } = doc.node(outer).kind else { + panic!("expected EntityRef, got {:?}", doc.node(outer).kind); + }; + assert_eq!(name, "b"); + let inner = doc.first_child(outer).unwrap(); + let NodeKind::EntityRef { ref name, .. } = doc.node(inner).kind else { + panic!("expected EntityRef, got {:?}", doc.node(inner).kind); + }; + assert_eq!(name, "a"); + } + + #[test] + fn test_parse_entity_amp_double_reference() { + // "&#38;amp;" builds replacement text "&amp;", which is then parsed + // as content, yielding "&". + let doc = parse("<!DOCTYPE d [<!ENTITY e \"&#38;amp;\">]><d>&e;</d>"); + let root = doc.root_element().unwrap(); + assert_eq!(doc.text_content(root), "&"); + } + + #[test] + fn test_parse_entity_markup_content() { + // Markup in the replacement text becomes real element children of + // the EntityRef node — not escaped text. + let doc = parse("<!DOCTYPE d [<!ENTITY e \"<b>hi</b>\">]><d>&e;</d>"); + let root = doc.root_element().unwrap(); + assert_eq!(doc.text_content(root), "hi"); + let entity_ref = doc.first_child(root).unwrap(); + let element = doc.first_child(entity_ref).unwrap(); + assert_eq!(doc.node_name(element), Some("b")); + // Serialization re-emits the entity reference, preserving roundtrip. + let out = crate::serial::serialize(&doc); + assert!( + out.contains("<d>&e;</d>"), + "unexpected serialization: {out}" + ); + } + + #[test] + fn test_parse_entity_charref_markup() { + // "&#60;b>hi&#60;/b>" expands to "<b>hi</b>" at declaration time + // (§4.5) and must be parsed as markup at reference time. + let doc = parse("<!DOCTYPE d [<!ENTITY e \"&#60;b>hi&#60;/b>\">]><d>&e;</d>"); + let root = doc.root_element().unwrap(); + let entity_ref = doc.first_child(root).unwrap(); + let element = doc.first_child(entity_ref).unwrap(); + assert_eq!(doc.node_name(element), Some("b")); + assert_eq!(doc.text_content(root), "hi"); + } + + #[test] + fn test_parse_entity_markup_roundtrip() { + // parse -> serialize -> parse must preserve the element structure + // inside the entity expansion. + let input = "<!DOCTYPE d [<!ENTITY e \"<b>hi</b>\">]><d>&e;</d>"; + let doc = parse(input); + let out = crate::serial::serialize(&doc); + let doc2 = parse(&out); + let root2 = doc2.root_element().unwrap(); + assert_eq!(doc2.text_content(root2), "hi"); + let entity_ref = doc2.first_child(root2).unwrap(); + let element = doc2.first_child(entity_ref).unwrap(); + assert_eq!(doc2.node_name(element), Some("b")); + } + + #[test] + fn test_parse_entity_unbalanced_markup_rejected() { + // §4.3.2: replacement text must match the content production; an + // unbalanced start tag is not well-formed. + let result = Document::parse_str("<!DOCTYPE d [<!ENTITY e \"<b>hi\">]><d>&e;</d>"); + assert!( + result.is_err(), + "unbalanced entity content must be rejected" + ); + } + + #[test] + fn test_parse_entity_split_tags_rejected() { + // A start tag in one entity and its end tag in another violates + // WFC: Element Type Match within the replacement text. + let result = Document::parse_str( + "<!DOCTYPE d [<!ENTITY open \"<b>\"><!ENTITY close \"</b>\">]><d>&open;hi&close;</d>", + ); + assert!(result.is_err(), "split tag pair must be rejected"); + } + + #[test] + fn test_parse_entity_namespace_inherited() { + // Elements produced by entity expansion resolve namespaces in scope + // at the point of reference. + let doc = parse("<!DOCTYPE d [<!ENTITY e \"<b/>\">]><d xmlns=\"urn:x\">&e;</d>"); + let root = doc.root_element().unwrap(); + let entity_ref = doc.first_child(root).unwrap(); + let element = doc.first_child(entity_ref).unwrap(); + let NodeKind::Element { ref namespace, .. } = doc.node(element).kind else { + panic!("expected Element, got {:?}", doc.node(element).kind); + }; + assert_eq!(namespace.as_deref(), Some("urn:x")); + } + + #[test] + fn test_parse_entity_nesting_depth_limit() { + // A 40-deep reference chain exceeds MAX_ENTITY_DEPTH when expanded. + use std::fmt::Write as _; + let mut dtd = String::from("<!ENTITY e0 \"<b/>\">"); + for i in 1..40u32 { + let _ = write!(dtd, "<!ENTITY e{i} \"<b>&e{};</b>\">", i - 1); + } + let xml = format!("<!DOCTYPE d [{dtd}]><d>&e39;</d>"); + let result = Document::parse_str(&xml); + let Err(err) = result else { + panic!("expected depth-limited parse to fail"); + }; + assert!( + err.message.contains("depth"), + "unexpected error message: {}", + err.message + ); + } + + #[test] + fn test_parse_entity_amplification_rejected() { + // Many references to a large entity trip the amplification guard + // (or the expansion counter) — billion-laughs protection. + let mut xml = String::from( + "<!DOCTYPE d [<!ENTITY a \"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\">\ + <!ENTITY b \"&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;\">\ + <!ENTITY c \"&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;\">\ + <!ENTITY e \"&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;\">]><d>", + ); + for _ in 0..20 { + xml.push_str("&e;"); + } + xml.push_str("</d>"); + let result = Document::parse_str(&xml); + let Err(err) = result else { + panic!("expected amplification attack to be rejected"); + }; + assert!( + err.message.contains("amplification") || err.message.contains("expansion limit"), + "unexpected error message: {}", + err.message + ); + } +} diff --git a/browser/vendor/xmloxide/src/reader/mod.rs b/browser/vendor/xmloxide/src/reader/mod.rs new file mode 100644 index 000000000..2687e38a3 --- /dev/null +++ b/browser/vendor/xmloxide/src/reader/mod.rs @@ -0,0 +1,1676 @@ +//! Pull-based streaming XML reader API. +//! +//! The `XmlReader` provides a cursor-style, pull-based interface for reading +//! XML documents. Instead of building a full tree in memory or requiring +//! callback implementations (SAX), the reader advances one node at a time +//! through the document, exposing the current node's properties via accessor +//! methods. +//! +//! This API is similar to libxml2's `xmlTextReader` and .NET's `XmlReader`. +//! +//! # Usage Pattern +//! +//! Call [`XmlReader::read`] repeatedly to advance through the document. Each +//! call moves the cursor to the next node. Use accessor methods like +//! [`XmlReader::node_type`], [`XmlReader::name`], and [`XmlReader::value`] +//! to inspect the current node. When `read()` returns `Ok(false)`, the end +//! of the document has been reached. +//! +//! # Examples +//! +//! ``` +//! use xmloxide::reader::{XmlReader, XmlNodeType}; +//! +//! let mut reader = XmlReader::new("<root><child>Hello</child></root>"); +//! let mut elements = Vec::new(); +//! +//! while reader.read().unwrap() { +//! if reader.node_type() == XmlNodeType::Element { +//! elements.push(reader.name().unwrap_or_default().to_string()); +//! } +//! } +//! +//! assert_eq!(elements, vec!["root", "child"]); +//! ``` + +use crate::error::{ErrorSeverity, ParseDiagnostic, ParseError}; +use crate::parser::input::{ + parse_cdata_content, parse_comment_content, parse_pi_content, parse_xml_decl, split_name, + NamespaceResolver, ParserInput, +}; +use crate::parser::ParseOptions; + +/// The type of the current node in the reader. +/// +/// These correspond to the different kinds of nodes that the reader can +/// be positioned on while traversing an XML document. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum XmlNodeType { + /// No node — the reader has not been advanced yet or is in an + /// indeterminate state. + None, + + /// An element start tag, e.g. `<div>` or `<br/>`. + /// + /// For self-closing elements (`<br/>`), [`XmlReader::is_empty_element`] + /// returns `true`. + Element, + + /// An element end tag, e.g. `</div>`. + /// + /// Self-closing elements do not produce a separate `EndElement` node. + EndElement, + + /// A text node containing character data. + Text, + + /// A CDATA section, e.g. `<![CDATA[...]]>`. + CData, + + /// An XML comment, e.g. `<!-- comment -->`. + Comment, + + /// A processing instruction, e.g. `<?target data?>`. + ProcessingInstruction, + + /// The XML declaration, e.g. `<?xml version="1.0"?>`. + XmlDeclaration, + + /// A document type declaration, e.g. `<!DOCTYPE html>`. + DocumentType, + + /// A whitespace-only text node in element content. + Whitespace, + + /// An attribute node — the reader is positioned on an attribute after + /// calling [`XmlReader::move_to_first_attribute`] or + /// [`XmlReader::move_to_next_attribute`]. + Attribute, + + /// The end of the document has been reached. + EndDocument, +} + +impl std::fmt::Display for XmlNodeType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::None => write!(f, "None"), + Self::Element => write!(f, "Element"), + Self::EndElement => write!(f, "EndElement"), + Self::Text => write!(f, "Text"), + Self::CData => write!(f, "CData"), + Self::Comment => write!(f, "Comment"), + Self::ProcessingInstruction => write!(f, "ProcessingInstruction"), + Self::XmlDeclaration => write!(f, "XmlDeclaration"), + Self::DocumentType => write!(f, "DocumentType"), + Self::Whitespace => write!(f, "Whitespace"), + Self::Attribute => write!(f, "Attribute"), + Self::EndDocument => write!(f, "EndDocument"), + } + } +} + +/// An attribute on the current element in the reader. +#[derive(Debug, Clone)] +struct ReaderAttribute { + /// The local name of the attribute. + local_name: String, + /// The attribute value. + value: String, + /// The namespace prefix, if any. + prefix: Option<String>, + /// The namespace URI, if any. + namespace_uri: Option<String>, +} + +/// Internal representation of a node the reader is positioned on. +#[derive(Debug, Clone)] +struct ReaderNode { + /// The type of this node. + node_type: XmlNodeType, + /// The local name (for elements, PIs) or the full name/target. + local_name: String, + /// The namespace prefix, if any. + prefix: Option<String>, + /// The namespace URI, if any. + namespace_uri: Option<String>, + /// The value/content (for text, comment, CDATA, PI data, attribute value). + value: Option<String>, + /// The depth of this node in the document tree. + depth: u32, + /// Whether this is an empty (self-closing) element. + is_empty_element: bool, + /// Attributes of the current element (empty for non-elements). + attributes: Vec<ReaderAttribute>, +} + +impl ReaderNode { + fn new(node_type: XmlNodeType) -> Self { + Self { + node_type, + local_name: String::new(), + prefix: None, + namespace_uri: None, + value: None, + depth: 0, + is_empty_element: false, + attributes: Vec::new(), + } + } +} + +/// A pull-based streaming XML reader. +/// +/// The reader parses an XML document incrementally, advancing one node at +/// a time. This is memory-efficient for large documents because it does not +/// build a full tree. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::reader::{XmlReader, XmlNodeType}; +/// +/// let mut reader = XmlReader::new("<doc attr=\"val\">text</doc>"); +/// +/// // Advance to <doc> +/// assert!(reader.read().unwrap()); +/// assert_eq!(reader.node_type(), XmlNodeType::Element); +/// assert_eq!(reader.name(), Some("doc")); +/// assert_eq!(reader.depth(), 0); +/// assert_eq!(reader.attribute_count(), 1); +/// assert_eq!(reader.get_attribute("attr"), Some("val")); +/// +/// // Advance to text content +/// assert!(reader.read().unwrap()); +/// assert_eq!(reader.node_type(), XmlNodeType::Text); +/// assert_eq!(reader.value(), Some("text")); +/// +/// // Advance to </doc> +/// assert!(reader.read().unwrap()); +/// assert_eq!(reader.node_type(), XmlNodeType::EndElement); +/// +/// // End of document +/// assert!(!reader.read().unwrap()); +/// ``` +#[allow(clippy::struct_excessive_bools)] +pub struct XmlReader<'a> { + /// Shared low-level input state (position, peek, advance, name parsing, etc.). + parser_input: ParserInput<'a>, + /// Parser options. + options: ParseOptions, + /// Namespace resolver managing the scope stack. + ns: NamespaceResolver, + /// The current node the reader is positioned on. + current: ReaderNode, + /// Queued nodes to emit before parsing more input. + /// For example, an element produces `Element` + `EndElement` for self-closing tags. + queue: Vec<ReaderNode>, + /// The current depth in the element tree. + depth: u32, + /// Whether parsing has started (first `read()` has been called). + started: bool, + /// Whether the document has ended. + finished: bool, + /// Whether we have parsed the prolog (xml decl, doctype, misc). + prolog_parsed: bool, + /// Whether the root element has been parsed. + root_parsed: bool, + /// Whether we are inside the root element content. + in_element_content: bool, + /// Stack of open element names (for matching end tags). + element_stack: Vec<String>, + /// Current attribute index when iterating over attributes. + attribute_index: Option<usize>, + /// The element node saved when navigating attributes. + saved_element: Option<ReaderNode>, +} + +impl<'a> XmlReader<'a> { + /// Creates a new `XmlReader` from a string slice with default options. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::reader::XmlReader; + /// + /// let mut reader = XmlReader::new("<root/>"); + /// assert!(reader.read().unwrap()); + /// ``` + #[must_use] + pub fn new(input: &'a str) -> Self { + Self::with_options(input, ParseOptions::default()) + } + + /// Creates a new `XmlReader` from a string slice with custom parse options. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::reader::XmlReader; + /// use xmloxide::parser::ParseOptions; + /// + /// let opts = ParseOptions::default().recover(true); + /// let mut reader = XmlReader::with_options("<root/>", opts); + /// assert!(reader.read().unwrap()); + /// ``` + #[must_use] + pub fn with_options(input: &'a str, options: ParseOptions) -> Self { + let mut pi = ParserInput::new(input); + pi.set_recover(options.recover); + pi.set_max_depth(options.max_depth); + pi.set_max_name_length(options.max_name_length); + pi.set_max_entity_expansions(options.max_entity_expansions); + pi.set_entity_resolver(options.entity_resolver.clone()); + + Self { + parser_input: pi, + options, + ns: NamespaceResolver::new(), + current: ReaderNode::new(XmlNodeType::None), + queue: Vec::new(), + depth: 0, + started: false, + finished: false, + prolog_parsed: false, + root_parsed: false, + in_element_content: false, + element_stack: Vec::new(), + attribute_index: None, + saved_element: None, + } + } + + // === Public API: reading === + + /// Advances the reader to the next node in the document. + /// + /// Returns `Ok(true)` if the reader successfully advanced to a node, + /// or `Ok(false)` if the end of the document has been reached. + /// + /// # Errors + /// + /// Returns `ParseError` if the XML is malformed and recovery mode is + /// not enabled. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::reader::XmlReader; + /// + /// let mut reader = XmlReader::new("<root/>"); + /// while reader.read().unwrap() { + /// // process each node + /// } + /// ``` + pub fn read(&mut self) -> Result<bool, ParseError> { + // Reset attribute navigation state when advancing. + self.attribute_index = None; + self.saved_element = None; + + if self.finished { + return Ok(false); + } + + // Drain queued nodes first. + if let Some(node) = self.queue.pop() { + self.current = node; + return Ok(true); + } + + if !self.started { + self.started = true; + } + + self.read_next_node() + } + + // === Public API: node type and properties === + + /// Returns the type of the current node. + /// + /// Before the first call to [`read`](Self::read), returns + /// [`XmlNodeType::None`]. + #[must_use] + pub fn node_type(&self) -> XmlNodeType { + self.current.node_type + } + + /// Returns the qualified name of the current node. + /// + /// For elements, this returns `prefix:localname` if a prefix is present, + /// or just `localname` otherwise. For processing instructions, this is + /// the target. For `DocumentType` nodes, this is the root element name. + /// For other node types, returns `None`. + #[must_use] + pub fn name(&self) -> Option<&str> { + match self.current.node_type { + XmlNodeType::Element + | XmlNodeType::EndElement + | XmlNodeType::ProcessingInstruction + | XmlNodeType::Attribute + | XmlNodeType::DocumentType => { + if self.current.local_name.is_empty() { + None + } else { + Some(&self.current.local_name) + } + } + _ => None, + } + } + + /// Returns the local name of the current node (without namespace prefix). + /// + /// For elements and attributes, this is the local part of the qualified + /// name. For processing instructions, this is the target. + #[must_use] + pub fn local_name(&self) -> Option<&str> { + match self.current.node_type { + XmlNodeType::Element + | XmlNodeType::EndElement + | XmlNodeType::ProcessingInstruction + | XmlNodeType::Attribute + | XmlNodeType::DocumentType => { + if self.current.local_name.is_empty() { + None + } else { + Some(&self.current.local_name) + } + } + _ => None, + } + } + + /// Returns the namespace prefix of the current node, if any. + /// + /// For elements and attributes with a prefix (e.g., `svg` in `<svg:rect>`), + /// returns the prefix string. For unprefixed elements, processing + /// instructions, text, comments, and other node types, returns `None`. + #[must_use] + pub fn prefix(&self) -> Option<&str> { + self.current.prefix.as_deref() + } + + /// Returns the namespace URI of the current node, if any. + /// + /// Namespace URIs are resolved for elements and attributes that are in a + /// namespace (either via a prefix or a default namespace declaration). + /// Returns `None` for nodes that have no namespace or for node types that + /// do not carry namespace information (text, comments, etc.). + #[must_use] + pub fn namespace_uri(&self) -> Option<&str> { + self.current.namespace_uri.as_deref() + } + + /// Returns the value of the current node, if applicable. + /// + /// For text, CDATA, comment, whitespace, and attribute nodes, this is + /// the text content. For processing instructions, this is the data + /// portion. For elements and end elements, returns `None`. + #[must_use] + pub fn value(&self) -> Option<&str> { + self.current.value.as_deref() + } + + /// Returns whether the current node has a value. + /// + /// Returns `true` for node types that carry text content: text, CDATA, + /// comment, whitespace, attribute, and processing instruction nodes. + /// Returns `false` for elements, end elements, and the document type. + #[must_use] + pub fn has_value(&self) -> bool { + self.current.value.is_some() + } + + /// Returns whether the current element is a self-closing (empty) element. + /// + /// Returns `true` for elements like `<br/>`, `false` for elements + /// like `<div>...</div>`. Always returns `false` for non-element nodes. + #[must_use] + pub fn is_empty_element(&self) -> bool { + self.current.is_empty_element + } + + /// Returns the depth of the current node in the document tree. + /// + /// The root element is at depth 0, its children at depth 1, and so on. + /// Nodes in the prolog (XML declaration, DOCTYPE) are at depth 0. + #[must_use] + pub fn depth(&self) -> u32 { + self.current.depth + } + + /// Returns the number of attributes on the current element. + /// + /// Returns 0 for non-element nodes. + #[must_use] + pub fn attribute_count(&self) -> usize { + self.current.attributes.len() + } + + /// Returns the value of an attribute by name on the current element. + /// + /// Searches by the full attribute name (qualified name). Returns `None` + /// if the attribute is not present or the current node is not an element. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::reader::{XmlReader, XmlNodeType}; + /// + /// let mut reader = XmlReader::new("<root id=\"42\"/>"); + /// reader.read().unwrap(); + /// assert_eq!(reader.get_attribute("id"), Some("42")); + /// assert_eq!(reader.get_attribute("missing"), None); + /// ``` + #[must_use] + pub fn get_attribute(&self, name: &str) -> Option<&str> { + let attrs = &self.current.attributes; + for attr in attrs { + let full_name = match &attr.prefix { + Some(pfx) => { + // Compare against "prefix:local_name" + if name.starts_with(pfx.as_str()) + && name.as_bytes().get(pfx.len()) == Some(&b':') + && name[pfx.len() + 1..] == *attr.local_name + { + return Some(&attr.value); + } + continue; + } + None => &attr.local_name, + }; + if full_name == name { + return Some(&attr.value); + } + } + None + } + + /// Returns the value of an attribute by local name and namespace URI. + /// + /// Returns `None` if the attribute is not present, the namespace does + /// not match, or the current node is not an element. + #[must_use] + pub fn get_attribute_ns(&self, local_name: &str, namespace_uri: &str) -> Option<&str> { + self.current.attributes.iter().find_map(|attr| { + if attr.local_name == local_name && attr.namespace_uri.as_deref() == Some(namespace_uri) + { + Some(attr.value.as_str()) + } else { + None + } + }) + } + + // === Public API: attribute navigation === + + /// Moves the reader to the first attribute of the current element. + /// + /// Returns `true` if the element has attributes and the reader was + /// moved to the first one. Returns `false` if there are no attributes + /// or the current node is not an element. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::reader::{XmlReader, XmlNodeType}; + /// + /// let mut reader = XmlReader::new("<root a=\"1\" b=\"2\"/>"); + /// reader.read().unwrap(); + /// + /// assert!(reader.move_to_first_attribute()); + /// assert_eq!(reader.node_type(), XmlNodeType::Attribute); + /// assert_eq!(reader.name(), Some("a")); + /// assert_eq!(reader.value(), Some("1")); + /// ``` + pub fn move_to_first_attribute(&mut self) -> bool { + if self.current.node_type != XmlNodeType::Element + && self.current.node_type != XmlNodeType::Attribute + { + return false; + } + + // Save the element node if we haven't already. + if self.saved_element.is_none() { + if self.current.node_type == XmlNodeType::Attribute { + // Already navigating attributes; don't overwrite saved. + } else { + self.saved_element = Some(self.current.clone()); + } + } + + let elem = self.saved_element.as_ref().unwrap_or(&self.current); + + if elem.attributes.is_empty() { + return false; + } + + let attr = &elem.attributes[0]; + self.current = ReaderNode { + node_type: XmlNodeType::Attribute, + local_name: attr.local_name.clone(), + prefix: attr.prefix.clone(), + namespace_uri: attr.namespace_uri.clone(), + value: Some(attr.value.clone()), + depth: elem.depth + 1, + is_empty_element: false, + attributes: elem.attributes.clone(), + }; + self.attribute_index = Some(0); + true + } + + /// Moves the reader to the next attribute of the current element. + /// + /// Returns `true` if there is a next attribute. Returns `false` if + /// there are no more attributes or the reader is not on an attribute. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::reader::{XmlReader, XmlNodeType}; + /// + /// let mut reader = XmlReader::new("<root a=\"1\" b=\"2\"/>"); + /// reader.read().unwrap(); + /// + /// assert!(reader.move_to_first_attribute()); + /// assert_eq!(reader.name(), Some("a")); + /// + /// assert!(reader.move_to_next_attribute()); + /// assert_eq!(reader.name(), Some("b")); + /// + /// assert!(!reader.move_to_next_attribute()); + /// ``` + pub fn move_to_next_attribute(&mut self) -> bool { + let Some(idx) = self.attribute_index else { + // If not currently on an attribute, try to start from the first. + return self.move_to_first_attribute(); + }; + + let elem = self.saved_element.as_ref().unwrap_or(&self.current); + + let next_idx = idx + 1; + if next_idx >= elem.attributes.len() { + return false; + } + + let attr = &elem.attributes[next_idx]; + self.current = ReaderNode { + node_type: XmlNodeType::Attribute, + local_name: attr.local_name.clone(), + prefix: attr.prefix.clone(), + namespace_uri: attr.namespace_uri.clone(), + value: Some(attr.value.clone()), + depth: elem.depth + 1, + is_empty_element: false, + attributes: elem.attributes.clone(), + }; + self.attribute_index = Some(next_idx); + true + } + + /// Moves the reader back to the element that owns the current attribute. + /// + /// Returns `true` if the reader was on an attribute and was moved back + /// to the element. Returns `false` if the reader was not on an attribute. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::reader::{XmlReader, XmlNodeType}; + /// + /// let mut reader = XmlReader::new("<root a=\"1\"/>"); + /// reader.read().unwrap(); + /// reader.move_to_first_attribute(); + /// assert_eq!(reader.node_type(), XmlNodeType::Attribute); + /// + /// assert!(reader.move_to_element()); + /// assert_eq!(reader.node_type(), XmlNodeType::Element); + /// assert_eq!(reader.name(), Some("root")); + /// ``` + pub fn move_to_element(&mut self) -> bool { + if let Some(elem) = self.saved_element.take() { + self.current = elem; + self.attribute_index = None; + true + } else { + false + } + } + + /// Returns the diagnostics collected during parsing. + /// + /// In recovery mode, this includes warnings and errors that were + /// encountered but did not halt parsing. + #[must_use] + pub fn diagnostics(&self) -> &[ParseDiagnostic] { + &self.parser_input.diagnostics + } + + // === Internal: top-level node dispatch === + + fn read_next_node(&mut self) -> Result<bool, ParseError> { + // Parse prolog if not done yet. + if !self.prolog_parsed { + return self.read_prolog(); + } + + // Parse root element and content. + if !self.root_parsed { + return self.read_root_or_prolog_misc(); + } + + // If inside element content, parse child nodes. + if self.in_element_content { + return self.read_element_content(); + } + + // After root element, parse trailing misc. + self.read_trailing_misc() + } + + fn read_prolog(&mut self) -> Result<bool, ParseError> { + self.parser_input.skip_whitespace(); + + // Parse XML declaration if present. + if self.parser_input.looking_at(b"<?xml ") + || self.parser_input.looking_at(b"<?xml\t") + || self.parser_input.looking_at(b"<?xml\r") + || self.parser_input.looking_at(b"<?xml?>") + { + let node = self.parse_xml_declaration()?; + self.current = node; + // Don't set prolog_parsed yet; there may be misc nodes. + return Ok(true); + } + + self.prolog_parsed = true; + self.read_root_or_prolog_misc() + } + + fn read_root_or_prolog_misc(&mut self) -> Result<bool, ParseError> { + self.parser_input.skip_whitespace(); + + if self.parser_input.at_end() { + self.finished = true; + self.current = ReaderNode::new(XmlNodeType::EndDocument); + return Ok(false); + } + + // DOCTYPE + if self.parser_input.looking_at(b"<!DOCTYPE") || self.parser_input.looking_at(b"<!doctype") + { + let node = self.parse_doctype()?; + self.current = node; + return Ok(true); + } + + // Comment + if self.parser_input.looking_at(b"<!--") { + let node = self.parse_comment()?; + self.current = node; + return Ok(true); + } + + // Processing instruction + if self.parser_input.looking_at(b"<?") { + let node = self.parse_processing_instruction()?; + self.current = node; + return Ok(true); + } + + // Root element start. + if self.parser_input.peek() == Some(b'<') + && self + .parser_input + .peek_at(1) + .is_some_and(|b| b != b'!' && b != b'?') + { + self.root_parsed = true; + let node = self.parse_element_start()?; + self.current = node; + return Ok(true); + } + + if !self.parser_input.at_end() && !self.options.recover { + return Err(self.parser_input.fatal("expected root element")); + } + + self.finished = true; + self.current = ReaderNode::new(XmlNodeType::EndDocument); + Ok(false) + } + + fn read_element_content(&mut self) -> Result<bool, ParseError> { + if self.parser_input.at_end() { + if self.options.recover { + // Force-close all open elements. + if let Some(name) = self.element_stack.pop() { + self.depth -= 1; + self.parser_input.decrement_depth(); + self.ns.pop_scope(); + let mut node = ReaderNode::new(XmlNodeType::EndElement); + let (prefix, local_name) = split_name(&name); + node.local_name = local_name.to_string(); + node.prefix = prefix.map(String::from); + node.depth = self.depth; + self.in_element_content = !self.element_stack.is_empty(); + self.current = node; + return Ok(true); + } + self.finished = true; + self.current = ReaderNode::new(XmlNodeType::EndDocument); + return Ok(false); + } + return Err(self + .parser_input + .fatal("unexpected end of input in element content")); + } + + // End tag. + if self.parser_input.looking_at(b"</") { + let node = self.parse_end_tag()?; + self.current = node; + return Ok(true); + } + + // CDATA section. + if self.parser_input.looking_at(b"<![CDATA[") { + let node = self.parse_cdata()?; + self.current = node; + return Ok(true); + } + + // Comment. + if self.parser_input.looking_at(b"<!--") { + let node = self.parse_comment()?; + self.current = node; + return Ok(true); + } + + // Processing instruction. + if self.parser_input.looking_at(b"<?") { + let node = self.parse_processing_instruction()?; + self.current = node; + return Ok(true); + } + + // Child element. + if self.parser_input.peek() == Some(b'<') + && self + .parser_input + .peek_at(1) + .is_some_and(|b| b != b'!' && b != b'?') + { + let node = self.parse_element_start()?; + self.current = node; + return Ok(true); + } + + // Character data (text). + let node = self.parse_char_data()?; + + // Skip whitespace-only text nodes if no_blanks is enabled. + if self.options.no_blanks && node.node_type == XmlNodeType::Whitespace { + return self.read_element_content(); + } + + self.current = node; + Ok(true) + } + + fn read_trailing_misc(&mut self) -> Result<bool, ParseError> { + self.parser_input.skip_whitespace(); + + if self.parser_input.at_end() { + self.finished = true; + self.current = ReaderNode::new(XmlNodeType::EndDocument); + return Ok(false); + } + + // Comment. + if self.parser_input.looking_at(b"<!--") { + let node = self.parse_comment()?; + self.current = node; + return Ok(true); + } + + // Processing instruction. + if self.parser_input.looking_at(b"<?") { + let node = self.parse_processing_instruction()?; + self.current = node; + return Ok(true); + } + + if !self.options.recover { + return Err(self.parser_input.fatal("content after document element")); + } + + self.finished = true; + self.current = ReaderNode::new(XmlNodeType::EndDocument); + Ok(false) + } + + // === Internal: parse individual constructs === + + fn parse_xml_declaration(&mut self) -> Result<ReaderNode, ParseError> { + let decl = parse_xml_decl(&mut self.parser_input)?; + self.prolog_parsed = true; + + // Build the value string as "version=X encoding=Y standalone=Z". + let mut value_parts = vec![format!("version={}", decl.version)]; + if let Some(ref enc) = decl.encoding { + value_parts.push(format!("encoding={enc}")); + } + if let Some(sa) = decl.standalone { + let sa_str = if sa { "yes" } else { "no" }; + value_parts.push(format!("standalone={sa_str}")); + } + + let mut node = ReaderNode::new(XmlNodeType::XmlDeclaration); + node.local_name = "xml".to_string(); + node.value = Some(value_parts.join(" ")); + node.depth = 0; + Ok(node) + } + + fn parse_doctype(&mut self) -> Result<ReaderNode, ParseError> { + // Parse: <!DOCTYPE name (SYSTEM|PUBLIC ...) [internal subset]? > + self.parser_input.expect_str(b"<!DOCTYPE")?; + self.parser_input.skip_whitespace_required()?; + let name = self.parser_input.parse_name()?; + self.parser_input.skip_whitespace(); + + if self.parser_input.looking_at(b"SYSTEM") { + self.parser_input.expect_str(b"SYSTEM")?; + self.parser_input.skip_whitespace_required()?; + self.parser_input.parse_quoted_value()?; + self.parser_input.skip_whitespace(); + } else if self.parser_input.looking_at(b"PUBLIC") { + self.parser_input.expect_str(b"PUBLIC")?; + self.parser_input.skip_whitespace_required()?; + self.parser_input.parse_quoted_value()?; + self.parser_input.skip_whitespace_required()?; + self.parser_input.parse_quoted_value()?; + self.parser_input.skip_whitespace(); + } + + if self.parser_input.peek() == Some(b'[') { + self.parser_input.advance(1); + let start = self.parser_input.pos(); + let mut bracket_depth: u32 = 1; + while !self.parser_input.at_end() && bracket_depth > 0 { + if self.parser_input.looking_at(b"<!--") { + self.parser_input.advance(4); + while !self.parser_input.at_end() && !self.parser_input.looking_at(b"-->") { + self.parser_input.advance(1); + } + if !self.parser_input.at_end() { + self.parser_input.advance(3); + } + } else if let Some(b'"' | b'\'') = self.parser_input.peek() { + let quote = self.parser_input.peek().unwrap_or(b'"'); + self.parser_input.advance(1); + while !self.parser_input.at_end() && self.parser_input.peek() != Some(quote) { + self.parser_input.advance(1); + } + if !self.parser_input.at_end() { + self.parser_input.advance(1); + } + } else if self.parser_input.peek() == Some(b'[') { + bracket_depth += 1; + self.parser_input.advance(1); + } else if self.parser_input.peek() == Some(b']') { + bracket_depth -= 1; + self.parser_input.advance(1); + } else { + self.parser_input.advance(1); + } + } + + // Parse DTD internal subset for entity declarations + let end = self.parser_input.pos() - 1; + let subset_text = std::str::from_utf8(self.parser_input.slice(start, end)) + .ok() + .map(str::to_string); + if let Some(subset_text) = subset_text { + if subset_text.contains('%') { + self.parser_input.has_pe_references = true; + } + if let Ok(dtd) = crate::validation::dtd::parse_dtd(&subset_text) { + for (ent_name, ent_decl) in &dtd.entities { + match &ent_decl.kind { + crate::validation::dtd::EntityKind::Internal(value) => { + self.parser_input + .entity_map + .insert(ent_name.clone(), value.clone()); + } + crate::validation::dtd::EntityKind::External { + system_id, + public_id, + } => { + self.parser_input.entity_external.insert( + ent_name.clone(), + crate::parser::input::ExternalEntityInfo { + system_id: system_id.clone(), + public_id: public_id.clone(), + }, + ); + } + } + } + } + } + + self.parser_input.skip_whitespace(); + } + + self.parser_input.expect_byte(b'>')?; + + let mut node = ReaderNode::new(XmlNodeType::DocumentType); + node.local_name = name; + node.depth = 0; + Ok(node) + } + + fn parse_element_start(&mut self) -> Result<ReaderNode, ParseError> { + self.parser_input.increment_depth()?; + self.parser_input.expect_byte(b'<')?; + let name = self.parser_input.parse_name()?; + + // Parse attributes. + let mut raw_attrs: Vec<(String, String)> = Vec::new(); + loop { + let had_ws = self.parser_input.skip_whitespace(); + if self.parser_input.peek() == Some(b'>') || self.parser_input.looking_at(b"/>") { + break; + } + if !had_ws { + return Err(self + .parser_input + .fatal("whitespace required between attributes")); + } + let attr_name = self.parser_input.parse_name()?; + self.parser_input.skip_whitespace(); + self.parser_input.expect_byte(b'=')?; + self.parser_input.skip_whitespace(); + let attr_value = self.parser_input.parse_attribute_value()?; + raw_attrs.push((attr_name, attr_value)); + } + + // Namespace processing. + self.ns.push_scope(); + for (attr_name, attr_value) in &raw_attrs { + if attr_name == "xmlns" { + self.ns.bind(None, attr_value.clone()); + } else if let Some(prefix) = attr_name.strip_prefix("xmlns:") { + self.ns.bind(Some(prefix.to_string()), attr_value.clone()); + } + } + + // Resolve element namespace. + let (prefix, local_name) = split_name(&name); + let elem_ns = self.ns.resolve(prefix).map(String::from); + + // Build attribute list. + let attributes: Vec<ReaderAttribute> = raw_attrs + .iter() + .map(|(attr_name, attr_value)| { + let (attr_prefix, attr_local) = split_name(attr_name); + let attr_ns = if attr_prefix == Some("xmlns") + || (attr_prefix.is_none() && attr_local == "xmlns") + { + None + } else { + attr_prefix + .and_then(|p| self.ns.resolve(Some(p))) + .map(String::from) + }; + ReaderAttribute { + local_name: attr_local.to_string(), + value: attr_value.clone(), + prefix: attr_prefix.map(String::from), + namespace_uri: attr_ns, + } + }) + .collect(); + + let is_empty = self.parser_input.looking_at(b"/>"); + if is_empty { + self.parser_input.advance(2); + } else { + self.parser_input.expect_byte(b'>')?; + } + + let current_depth = self.depth; + + let mut node = ReaderNode::new(XmlNodeType::Element); + node.local_name = local_name.to_string(); + node.prefix = prefix.map(String::from); + node.namespace_uri = elem_ns; + node.depth = current_depth; + node.is_empty_element = is_empty; + node.attributes = attributes; + + if is_empty { + // For empty elements, by convention in .NET-style readers, we + // do NOT emit a separate EndElement. The is_empty_element flag + // signals the caller. We do however need to pop the ns scope + // and decrement the security depth counter. + self.ns.pop_scope(); + self.parser_input.decrement_depth(); + } else { + self.element_stack.push(name); + self.depth += 1; + self.in_element_content = true; + } + + Ok(node) + } + + fn parse_end_tag(&mut self) -> Result<ReaderNode, ParseError> { + self.parser_input.expect_str(b"</")?; + let name = self.parser_input.parse_name()?; + self.parser_input.skip_whitespace(); + self.parser_input.expect_byte(b'>')?; + + // Match against the open element stack. + if let Some(expected) = self.element_stack.last() { + if *expected != name { + if self.options.recover { + self.parser_input.push_diagnostic( + ErrorSeverity::Error, + format!("mismatched end tag: expected </{expected}>, found </{name}>"), + ); + } else { + return Err(self.parser_input.fatal(format!( + "mismatched end tag: expected </{expected}>, found </{name}>" + ))); + } + } + } + + self.element_stack.pop(); + self.depth -= 1; + self.parser_input.decrement_depth(); + self.ns.pop_scope(); + self.in_element_content = !self.element_stack.is_empty(); + + let (prefix, local_name) = split_name(&name); + let mut node = ReaderNode::new(XmlNodeType::EndElement); + node.local_name = local_name.to_string(); + node.prefix = prefix.map(String::from); + node.depth = self.depth; + + Ok(node) + } + + fn parse_char_data(&mut self) -> Result<ReaderNode, ParseError> { + let mut text = String::new(); + while !self.parser_input.at_end() { + if self.parser_input.peek() == Some(b'<') { + break; + } + + // XML 1.0 §2.4: "]]>" is forbidden in character data + if self.parser_input.looking_at(b"]]>") { + if self.options.recover { + self.parser_input.push_diagnostic( + ErrorSeverity::Error, + "']]>' not allowed in character data".to_string(), + ); + text.push_str("]]>"); + self.parser_input.advance(3); + continue; + } + return Err(self + .parser_input + .fatal("']]>' not allowed in character data")); + } + + if self.parser_input.peek() == Some(b'&') { + self.parser_input.parse_reference_into(&mut text)?; + } else { + let ch = self.parser_input.next_char()?; + text.push(ch); + } + } + + let is_whitespace = text + .chars() + .all(|c| c == ' ' || c == '\t' || c == '\n' || c == '\r'); + + let node_type = if is_whitespace { + XmlNodeType::Whitespace + } else { + XmlNodeType::Text + }; + + let mut node = ReaderNode::new(node_type); + node.value = Some(text); + node.depth = self.depth; + Ok(node) + } + + fn parse_comment(&mut self) -> Result<ReaderNode, ParseError> { + let content = parse_comment_content(&mut self.parser_input)?; + let mut node = ReaderNode::new(XmlNodeType::Comment); + node.value = Some(content); + node.depth = self.depth; + Ok(node) + } + + fn parse_cdata(&mut self) -> Result<ReaderNode, ParseError> { + let content = parse_cdata_content(&mut self.parser_input)?; + let mut node = ReaderNode::new(XmlNodeType::CData); + node.value = Some(content); + node.depth = self.depth; + Ok(node) + } + + fn parse_processing_instruction(&mut self) -> Result<ReaderNode, ParseError> { + let (target, data) = parse_pi_content(&mut self.parser_input)?; + let mut node = ReaderNode::new(XmlNodeType::ProcessingInstruction); + node.local_name = target; + node.value = data; + node.depth = self.depth; + Ok(node) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + // --- Helper --- + + fn read_all_types(input: &str) -> Vec<(XmlNodeType, String)> { + let mut reader = XmlReader::new(input); + let mut result = Vec::new(); + while reader.read().unwrap() { + let label = match reader.node_type() { + XmlNodeType::Element | XmlNodeType::EndElement => { + reader.name().unwrap_or("").to_string() + } + XmlNodeType::Text + | XmlNodeType::CData + | XmlNodeType::Comment + | XmlNodeType::Whitespace + | XmlNodeType::XmlDeclaration => reader.value().unwrap_or("").to_string(), + XmlNodeType::ProcessingInstruction => { + let target = reader.name().unwrap_or("").to_string(); + match reader.value() { + Some(data) => format!("{target} {data}"), + None => target, + } + } + XmlNodeType::DocumentType => reader.name().unwrap_or("").to_string(), + _ => String::new(), + }; + result.push((reader.node_type(), label)); + } + result + } + + // === Test: basic element === + + #[test] + fn test_read_empty_element() { + let mut reader = XmlReader::new("<root/>"); + assert!(reader.read().unwrap()); + assert_eq!(reader.node_type(), XmlNodeType::Element); + assert_eq!(reader.name(), Some("root")); + assert!(reader.is_empty_element()); + assert_eq!(reader.depth(), 0); + + // No EndElement for empty elements. + assert!(!reader.read().unwrap()); + } + + #[test] + fn test_read_element_with_content() { + let nodes = read_all_types("<root>Hello</root>"); + assert_eq!( + nodes, + vec![ + (XmlNodeType::Element, "root".to_string()), + (XmlNodeType::Text, "Hello".to_string()), + (XmlNodeType::EndElement, "root".to_string()), + ] + ); + } + + #[test] + fn test_read_nested_elements() { + let nodes = read_all_types("<a><b>text</b></a>"); + assert_eq!( + nodes, + vec![ + (XmlNodeType::Element, "a".to_string()), + (XmlNodeType::Element, "b".to_string()), + (XmlNodeType::Text, "text".to_string()), + (XmlNodeType::EndElement, "b".to_string()), + (XmlNodeType::EndElement, "a".to_string()), + ] + ); + } + + // === Test: depth tracking === + + #[test] + fn test_read_depth_tracking() { + let mut reader = XmlReader::new("<a><b><c/></b></a>"); + + reader.read().unwrap(); // <a> + assert_eq!(reader.depth(), 0); + assert_eq!(reader.name(), Some("a")); + + reader.read().unwrap(); // <b> + assert_eq!(reader.depth(), 1); + assert_eq!(reader.name(), Some("b")); + + reader.read().unwrap(); // <c/> + assert_eq!(reader.depth(), 2); + assert_eq!(reader.name(), Some("c")); + assert!(reader.is_empty_element()); + + reader.read().unwrap(); // </b> + assert_eq!(reader.depth(), 1); + assert_eq!(reader.node_type(), XmlNodeType::EndElement); + + reader.read().unwrap(); // </a> + assert_eq!(reader.depth(), 0); + assert_eq!(reader.node_type(), XmlNodeType::EndElement); + + assert!(!reader.read().unwrap()); // EOF + } + + // === Test: attributes === + + #[test] + fn test_read_attributes() { + let mut reader = XmlReader::new("<root id=\"1\" class=\"big\"/>"); + reader.read().unwrap(); + + assert_eq!(reader.attribute_count(), 2); + assert_eq!(reader.get_attribute("id"), Some("1")); + assert_eq!(reader.get_attribute("class"), Some("big")); + assert_eq!(reader.get_attribute("missing"), None); + } + + #[test] + fn test_attribute_navigation() { + let mut reader = XmlReader::new("<root a=\"1\" b=\"2\" c=\"3\"/>"); + reader.read().unwrap(); + assert_eq!(reader.node_type(), XmlNodeType::Element); + + // Move to first attribute. + assert!(reader.move_to_first_attribute()); + assert_eq!(reader.node_type(), XmlNodeType::Attribute); + assert_eq!(reader.name(), Some("a")); + assert_eq!(reader.value(), Some("1")); + + // Move to second attribute. + assert!(reader.move_to_next_attribute()); + assert_eq!(reader.name(), Some("b")); + assert_eq!(reader.value(), Some("2")); + + // Move to third attribute. + assert!(reader.move_to_next_attribute()); + assert_eq!(reader.name(), Some("c")); + assert_eq!(reader.value(), Some("3")); + + // No more attributes. + assert!(!reader.move_to_next_attribute()); + + // Move back to element. + assert!(reader.move_to_element()); + assert_eq!(reader.node_type(), XmlNodeType::Element); + assert_eq!(reader.name(), Some("root")); + } + + // === Test: text and whitespace === + + #[test] + fn test_read_text_content() { + let mut reader = XmlReader::new("<p>Hello &amp; world</p>"); + reader.read().unwrap(); // <p> + reader.read().unwrap(); // text + assert_eq!(reader.node_type(), XmlNodeType::Text); + assert_eq!(reader.value(), Some("Hello & world")); + assert!(reader.has_value()); + } + + #[test] + fn test_read_whitespace_only_text() { + let mut reader = XmlReader::new("<root> \n </root>"); + reader.read().unwrap(); // <root> + reader.read().unwrap(); // whitespace + assert_eq!(reader.node_type(), XmlNodeType::Whitespace); + assert_eq!(reader.value(), Some(" \n ")); + } + + #[test] + fn test_read_no_blanks_option() { + let opts = ParseOptions::default().no_blanks(true); + let mut reader = XmlReader::with_options("<root> <child/> </root>", opts); + + reader.read().unwrap(); // <root> + assert_eq!(reader.name(), Some("root")); + + reader.read().unwrap(); // <child/> (whitespace skipped) + assert_eq!(reader.node_type(), XmlNodeType::Element); + assert_eq!(reader.name(), Some("child")); + + reader.read().unwrap(); // </root> (whitespace skipped) + assert_eq!(reader.node_type(), XmlNodeType::EndElement); + assert_eq!(reader.name(), Some("root")); + } + + // === Test: comments, CDATA, PI === + + #[test] + fn test_read_comment() { + let nodes = read_all_types("<root><!-- hello --></root>"); + assert_eq!( + nodes, + vec![ + (XmlNodeType::Element, "root".to_string()), + (XmlNodeType::Comment, " hello ".to_string()), + (XmlNodeType::EndElement, "root".to_string()), + ] + ); + } + + #[test] + fn test_read_cdata() { + let nodes = read_all_types("<root><![CDATA[raw & data]]></root>"); + assert_eq!( + nodes, + vec![ + (XmlNodeType::Element, "root".to_string()), + (XmlNodeType::CData, "raw & data".to_string()), + (XmlNodeType::EndElement, "root".to_string()), + ] + ); + } + + #[test] + fn test_read_processing_instruction() { + let nodes = read_all_types("<?target data?><root/>"); + assert_eq!( + nodes, + vec![ + ( + XmlNodeType::ProcessingInstruction, + "target data".to_string() + ), + (XmlNodeType::Element, "root".to_string()), + ] + ); + } + + // === Test: XML declaration and doctype === + + #[test] + fn test_read_xml_declaration() { + let nodes = read_all_types("<?xml version=\"1.0\" encoding=\"UTF-8\"?><root/>"); + assert_eq!( + nodes, + vec![ + ( + XmlNodeType::XmlDeclaration, + "version=1.0 encoding=UTF-8".to_string() + ), + (XmlNodeType::Element, "root".to_string()), + ] + ); + } + + #[test] + fn test_read_doctype() { + let nodes = read_all_types("<!DOCTYPE html><html/>"); + assert_eq!( + nodes, + vec![ + (XmlNodeType::DocumentType, "html".to_string()), + (XmlNodeType::Element, "html".to_string()), + ] + ); + } + + // === Test: namespaces === + + #[test] + fn test_read_namespace() { + let mut reader = XmlReader::new("<root xmlns=\"http://example.com\"/>"); + reader.read().unwrap(); + assert_eq!(reader.name(), Some("root")); + assert_eq!(reader.namespace_uri(), Some("http://example.com")); + assert_eq!(reader.prefix(), None); + } + + #[test] + fn test_read_prefixed_namespace() { + let mut reader = XmlReader::new("<ns:root xmlns:ns=\"http://example.com\"/>"); + reader.read().unwrap(); + assert_eq!(reader.name(), Some("root")); + assert_eq!(reader.prefix(), Some("ns")); + assert_eq!(reader.namespace_uri(), Some("http://example.com")); + } + + #[test] + fn test_read_attribute_ns() { + let mut reader = XmlReader::new("<root xmlns:x=\"http://x.com\" x:attr=\"val\"/>"); + reader.read().unwrap(); + assert_eq!(reader.get_attribute("x:attr"), Some("val")); + assert_eq!(reader.get_attribute_ns("attr", "http://x.com"), Some("val")); + assert_eq!(reader.get_attribute_ns("attr", "http://other.com"), None); + } + + // === Test: mixed content === + + #[test] + fn test_read_mixed_content() { + let nodes = read_all_types("<p>Hello <b>world</b>!</p>"); + assert_eq!( + nodes, + vec![ + (XmlNodeType::Element, "p".to_string()), + (XmlNodeType::Text, "Hello ".to_string()), + (XmlNodeType::Element, "b".to_string()), + (XmlNodeType::Text, "world".to_string()), + (XmlNodeType::EndElement, "b".to_string()), + (XmlNodeType::Text, "!".to_string()), + (XmlNodeType::EndElement, "p".to_string()), + ] + ); + } + + // === Test: entity references === + + #[test] + fn test_read_entity_references() { + let mut reader = XmlReader::new("<root>&amp;&lt;&gt;&apos;&quot;</root>"); + reader.read().unwrap(); // <root> + reader.read().unwrap(); // text + assert_eq!(reader.value(), Some("&<>'\"")); + } + + // === Test: character references === + + #[test] + fn test_read_character_references() { + let mut reader = XmlReader::new("<root>&#65;&#x42;</root>"); + reader.read().unwrap(); // <root> + reader.read().unwrap(); // text "AB" + assert_eq!(reader.value(), Some("AB")); + } + + // === Test: error handling === + + #[test] + fn test_read_error_mismatched_tags() { + let mut reader = XmlReader::new("<a></b>"); + reader.read().unwrap(); // <a> + let result = reader.read(); // </b> should fail + // The read of text between <a> and </b> will give us the end tag. + // Actually there's no text, so we'll get the mismatched end tag error. + assert!(result.is_err()); + } + + #[test] + fn test_read_returns_false_after_end() { + let mut reader = XmlReader::new("<root/>"); + assert!(reader.read().unwrap()); // <root/> + assert!(!reader.read().unwrap()); // EOF + assert!(!reader.read().unwrap()); // still EOF + } + + // === Test: XmlNodeType Display === + + #[test] + fn test_node_type_display() { + assert_eq!(XmlNodeType::Element.to_string(), "Element"); + assert_eq!(XmlNodeType::EndElement.to_string(), "EndElement"); + assert_eq!(XmlNodeType::Text.to_string(), "Text"); + assert_eq!(XmlNodeType::None.to_string(), "None"); + assert_eq!(XmlNodeType::EndDocument.to_string(), "EndDocument"); + } + + // === Test: has_value returns false for elements === + + #[test] + fn test_has_value_element() { + let mut reader = XmlReader::new("<root/>"); + reader.read().unwrap(); + assert_eq!(reader.node_type(), XmlNodeType::Element); + assert!(!reader.has_value()); + } + + // === Test: value returns None for element === + + #[test] + fn test_value_none_for_element() { + let mut reader = XmlReader::new("<root/>"); + reader.read().unwrap(); + assert_eq!(reader.value(), None); + } + + // === Test: initial state === + + #[test] + fn test_initial_state() { + let reader = XmlReader::new("<root/>"); + assert_eq!(reader.node_type(), XmlNodeType::None); + assert_eq!(reader.name(), None); + assert_eq!(reader.value(), None); + assert!(!reader.has_value()); + assert_eq!(reader.depth(), 0); + assert_eq!(reader.attribute_count(), 0); + } + + // === Test: complex document === + + #[test] + fn test_read_complex_document() { + let xml = r#"<?xml version="1.0"?> +<!DOCTYPE doc> +<!-- prolog comment --> +<?style type="text/css"?> +<doc attr="val"> + <child>text</child> + <![CDATA[raw]]> + <!-- inner comment --> + <empty/> +</doc>"#; + let nodes = read_all_types(xml); + // Verify we get all the expected node types. + let types: Vec<XmlNodeType> = nodes.iter().map(|(t, _)| *t).collect(); + assert!(types.contains(&XmlNodeType::XmlDeclaration)); + assert!(types.contains(&XmlNodeType::DocumentType)); + assert!(types.contains(&XmlNodeType::Comment)); + assert!(types.contains(&XmlNodeType::ProcessingInstruction)); + assert!(types.contains(&XmlNodeType::Element)); + assert!(types.contains(&XmlNodeType::Text)); + assert!(types.contains(&XmlNodeType::CData)); + assert!(types.contains(&XmlNodeType::EndElement)); + } + + // === Test: prolog comments and PIs === + + #[test] + fn test_read_prolog_comment() { + let nodes = read_all_types("<!-- prolog --><root/>"); + assert_eq!( + nodes, + vec![ + (XmlNodeType::Comment, " prolog ".to_string()), + (XmlNodeType::Element, "root".to_string()), + ] + ); + } + + // === Test: trailing comments === + + #[test] + fn test_read_trailing_comment() { + let nodes = read_all_types("<root/><!-- trailing -->"); + assert_eq!( + nodes, + vec![ + (XmlNodeType::Element, "root".to_string()), + (XmlNodeType::Comment, " trailing ".to_string()), + ] + ); + } + + // === Test: move_to_element returns false when not on attribute === + + #[test] + fn test_move_to_element_when_not_on_attribute() { + let mut reader = XmlReader::new("<root/>"); + reader.read().unwrap(); + assert!(!reader.move_to_element()); + } + + // === Test: empty document === + + #[test] + fn test_read_empty_input() { + let mut reader = XmlReader::new(""); + assert!(!reader.read().unwrap()); + } + + // === Test: deeply nested === + + #[test] + fn test_read_deeply_nested() { + let mut reader = XmlReader::new("<a><b><c><d><e>deep</e></d></c></b></a>"); + + reader.read().unwrap(); // <a> depth=0 + assert_eq!(reader.depth(), 0); + reader.read().unwrap(); // <b> depth=1 + assert_eq!(reader.depth(), 1); + reader.read().unwrap(); // <c> depth=2 + assert_eq!(reader.depth(), 2); + reader.read().unwrap(); // <d> depth=3 + assert_eq!(reader.depth(), 3); + reader.read().unwrap(); // <e> depth=4 + assert_eq!(reader.depth(), 4); + reader.read().unwrap(); // "deep" + assert_eq!(reader.depth(), 5); + assert_eq!(reader.value(), Some("deep")); + } + + // === Test: single-quoted attributes === + + #[test] + fn test_read_single_quoted_attributes() { + let mut reader = XmlReader::new("<root attr='value'/>"); + reader.read().unwrap(); + assert_eq!(reader.get_attribute("attr"), Some("value")); + } +} diff --git a/browser/vendor/xmloxide/src/sax/mod.rs b/browser/vendor/xmloxide/src/sax/mod.rs new file mode 100644 index 000000000..4a628a083 --- /dev/null +++ b/browser/vendor/xmloxide/src/sax/mod.rs @@ -0,0 +1,915 @@ +//! SAX2 streaming event handler API. +//! +//! SAX (Simple API for XML) is a streaming, event-driven API for processing +//! XML. Instead of building a tree in memory, the parser fires callbacks as it +//! encounters elements, text, comments, and other XML constructs. +//! +//! This is useful for large documents where building a full tree would be +//! wasteful, or when you only need to extract specific data. +//! +//! # Examples +//! +//! ``` +//! use xmloxide::sax::{SaxHandler, parse_sax, DefaultHandler}; +//! use xmloxide::parser::ParseOptions; +//! +//! struct MyHandler { +//! element_count: usize, +//! } +//! +//! impl SaxHandler for MyHandler { +//! fn start_element( +//! &mut self, +//! local_name: &str, +//! _prefix: Option<&str>, +//! _namespace: Option<&str>, +//! _attributes: &[(String, String, Option<String>, Option<String>)], +//! ) { +//! self.element_count += 1; +//! } +//! } +//! +//! let mut handler = MyHandler { element_count: 0 }; +//! parse_sax("<root><a/><b/><c/></root>", &ParseOptions::default(), &mut handler).unwrap(); +//! assert_eq!(handler.element_count, 4); +//! ``` + +use crate::error::{ErrorSeverity, ParseError, SourceLocation}; +use crate::parser::input::{ + parse_cdata_content, parse_comment_content, parse_pi_content, parse_xml_decl, split_name, + NamespaceResolver, ParserInput, +}; +use crate::parser::ParseOptions; + +/// A SAX2 event handler trait. +/// +/// Implement the callbacks you care about; all methods have default no-op +/// implementations so you only need to override what you need. +/// +/// # Attribute tuples +/// +/// Attributes are passed as `(local_name, value, prefix, namespace_uri)` tuples. +#[allow(unused_variables)] +pub trait SaxHandler { + /// Called at the start of the document, before any other events. + fn start_document(&mut self) {} + + /// Called at the end of the document, after all other events. + fn end_document(&mut self) {} + + /// Called when an element start tag is encountered. + /// + /// `attributes` contains `(local_name, value, prefix, namespace_uri)` tuples. + fn start_element( + &mut self, + local_name: &str, + prefix: Option<&str>, + namespace: Option<&str>, + attributes: &[(String, String, Option<String>, Option<String>)], + ) { + } + + /// Called when an element end tag is encountered (or a self-closing tag ends). + fn end_element(&mut self, local_name: &str, prefix: Option<&str>, namespace: Option<&str>) {} + + /// Called for character data (text content). + fn characters(&mut self, content: &str) {} + + /// Called for CDATA sections. + fn cdata(&mut self, content: &str) {} + + /// Called for XML comments. + fn comment(&mut self, content: &str) {} + + /// Called for processing instructions. + fn processing_instruction(&mut self, target: &str, data: Option<&str>) {} + + /// Called when a warning is encountered during parsing. + fn warning(&mut self, message: &str, location: SourceLocation) {} + + /// Called when a recoverable error is encountered during parsing. + fn error(&mut self, message: &str, location: SourceLocation) {} +} + +/// A default no-op SAX handler. Useful as a base or for testing. +pub struct DefaultHandler; + +impl SaxHandler for DefaultHandler {} + +/// Parses XML from a string, firing SAX events on the provided handler. +/// +/// # Errors +/// +/// Returns `ParseError` if the input is not well-formed XML and recovery +/// mode is not enabled. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::sax::{parse_sax, DefaultHandler}; +/// use xmloxide::parser::ParseOptions; +/// +/// let mut handler = DefaultHandler; +/// parse_sax("<root/>", &ParseOptions::default(), &mut handler).unwrap(); +/// ``` +pub fn parse_sax( + input: &str, + options: &ParseOptions, + handler: &mut dyn SaxHandler, +) -> Result<(), ParseError> { + let mut parser = SaxParser::new(input, options, handler); + let result = parser.parse(); + + // Transfer diagnostics from the shared input into any error that is + // returned, so callers see the full diagnostic trail. + if let Err(ref _e) = result { + // The error already contains diagnostics from ParserInput::fatal(). + } + + result +} + +/// The SAX-driven XML parser. +/// +/// Reuses the same parsing logic as the tree-building parser but fires +/// SAX events instead of constructing nodes. +struct SaxParser<'a, 'h> { + /// Shared low-level input state (position, peek, advance, name parsing, etc.). + input: ParserInput<'a>, + /// Parser options. + options: ParseOptions, + /// SAX event handler. + handler: &'h mut dyn SaxHandler, + /// Namespace resolver managing the scope stack. + ns: NamespaceResolver, +} + +impl<'a, 'h> SaxParser<'a, 'h> { + fn new(input: &'a str, options: &ParseOptions, handler: &'h mut dyn SaxHandler) -> Self { + let mut pi = ParserInput::new(input); + pi.set_recover(options.recover); + pi.set_max_depth(options.max_depth); + pi.set_max_name_length(options.max_name_length); + pi.set_max_entity_expansions(options.max_entity_expansions); + pi.set_entity_resolver(options.entity_resolver.clone()); + + Self { + input: pi, + options: options.clone(), + handler, + ns: NamespaceResolver::new(), + } + } + + fn parse(&mut self) -> Result<(), ParseError> { + self.handler.start_document(); + + // Parse optional XML declaration + self.input.skip_whitespace(); + if self.input.looking_at(b"<?xml ") + || self.input.looking_at(b"<?xml\t") + || self.input.looking_at(b"<?xml\r") + { + self.parse_xml_declaration()?; + } + + // Parse prolog misc + self.parse_misc()?; + + // Parse optional DOCTYPE + if self.input.looking_at(b"<!DOCTYPE") || self.input.looking_at(b"<!doctype") { + self.skip_doctype()?; + self.parse_misc()?; + } + + // Parse root element + if self.input.peek() == Some(b'<') + && self + .input + .peek_at(1) + .is_some_and(|b| b != b'!' && b != b'?') + { + self.parse_element()?; + } + + // Parse trailing misc + self.parse_misc()?; + + self.input.skip_whitespace(); + if !self.input.at_end() && !self.options.recover { + return Err(self.input.fatal("content after document element")); + } + + self.handler.end_document(); + Ok(()) + } + + // --- XML Declaration --- + // See XML 1.0 §2.8: [23] XMLDecl + + fn parse_xml_declaration(&mut self) -> Result<(), ParseError> { + // Delegate to the shared XML declaration parser; we discard the + // parsed values because the SAX API does not expose them. + let _decl = parse_xml_decl(&mut self.input)?; + Ok(()) + } + + // --- Misc (comments, PIs, whitespace) --- + + fn parse_misc(&mut self) -> Result<(), ParseError> { + loop { + self.input.skip_whitespace(); + if self.input.at_end() { + break; + } + if self.input.looking_at(b"<!--") { + self.parse_comment()?; + } else if self.input.looking_at(b"<?") { + self.parse_processing_instruction()?; + } else { + break; + } + } + Ok(()) + } + + // --- DOCTYPE Declaration --- + // See XML 1.0 §2.8: [28] doctypedecl + + fn skip_doctype(&mut self) -> Result<(), ParseError> { + self.input.expect_str(b"<!DOCTYPE")?; + self.input.skip_whitespace_required()?; + self.input.parse_name()?; + self.input.skip_whitespace(); + + if self.input.looking_at(b"SYSTEM") { + self.input.expect_str(b"SYSTEM")?; + self.input.skip_whitespace_required()?; + self.input.parse_quoted_value()?; + self.input.skip_whitespace(); + } else if self.input.looking_at(b"PUBLIC") { + self.input.expect_str(b"PUBLIC")?; + self.input.skip_whitespace_required()?; + self.input.parse_quoted_value()?; + self.input.skip_whitespace_required()?; + self.input.parse_quoted_value()?; + self.input.skip_whitespace(); + } + + if self.input.peek() == Some(b'[') { + self.input.advance(1); + let start = self.input.pos(); + let mut depth: u32 = 1; + while !self.input.at_end() && depth > 0 { + if self.input.looking_at(b"<!--") { + self.input.advance(4); + while !self.input.at_end() && !self.input.looking_at(b"-->") { + self.input.advance(1); + } + if !self.input.at_end() { + self.input.advance(3); + } + } else if let Some(b'"' | b'\'') = self.input.peek() { + let quote = self.input.peek().unwrap_or(b'"'); + self.input.advance(1); + while !self.input.at_end() && self.input.peek() != Some(quote) { + self.input.advance(1); + } + if !self.input.at_end() { + self.input.advance(1); + } + } else if self.input.peek() == Some(b'[') { + depth += 1; + self.input.advance(1); + } else if self.input.peek() == Some(b']') { + depth -= 1; + self.input.advance(1); + } else { + self.input.advance(1); + } + } + + // Parse DTD internal subset for entity declarations + let end = self.input.pos() - 1; + let subset_text = std::str::from_utf8(self.input.slice(start, end)) + .ok() + .map(str::to_string); + if let Some(subset_text) = subset_text { + if subset_text.contains('%') { + self.input.has_pe_references = true; + } + if let Ok(dtd) = crate::validation::dtd::parse_dtd(&subset_text) { + for (ent_name, ent_decl) in &dtd.entities { + match &ent_decl.kind { + crate::validation::dtd::EntityKind::Internal(value) => { + self.input + .entity_map + .insert(ent_name.clone(), value.clone()); + } + crate::validation::dtd::EntityKind::External { + system_id, + public_id, + } => { + self.input.entity_external.insert( + ent_name.clone(), + crate::parser::input::ExternalEntityInfo { + system_id: system_id.clone(), + public_id: public_id.clone(), + }, + ); + } + } + } + } + } + + self.input.skip_whitespace(); + } + + self.input.expect_byte(b'>')?; + Ok(()) + } + + // --- Elements --- + // See XML 1.0 §3.1: [40] STag, [42] ETag, [44] EmptyElemTag + + fn parse_element(&mut self) -> Result<(), ParseError> { + self.input.increment_depth()?; + self.input.expect_byte(b'<')?; + let name = self.input.parse_name()?; + + // Parse attributes as (full_name, value) pairs first + let mut raw_attrs: Vec<(String, String)> = Vec::new(); + loop { + let had_ws = self.input.skip_whitespace(); + if self.input.peek() == Some(b'>') || self.input.looking_at(b"/>") { + break; + } + if !had_ws { + return Err(self.input.fatal("whitespace required between attributes")); + } + let attr_name = self.input.parse_name()?; + self.input.skip_whitespace(); + self.input.expect_byte(b'=')?; + self.input.skip_whitespace(); + let attr_value = self.input.parse_attribute_value()?; + raw_attrs.push((attr_name, attr_value)); + } + + // Namespace processing: push scope and bind declarations. + self.ns.push_scope(); + for (attr_name, attr_value) in &raw_attrs { + if attr_name == "xmlns" { + self.ns.bind(None, attr_value.clone()); + } else if let Some(prefix) = attr_name.strip_prefix("xmlns:") { + self.ns.bind(Some(prefix.to_string()), attr_value.clone()); + } + } + + // Resolve element namespace + let (prefix, local_name) = split_name(&name); + let elem_ns = self.ns.resolve(prefix).map(String::from); + + // Build attribute tuples: (local_name, value, prefix, namespace) + let attributes: Vec<(String, String, Option<String>, Option<String>)> = raw_attrs + .iter() + .map(|(attr_name, attr_value)| { + let (attr_prefix, attr_local) = split_name(attr_name); + let attr_ns = if attr_prefix == Some("xmlns") + || (attr_prefix.is_none() && attr_local == "xmlns") + { + None + } else { + attr_prefix.and_then(|p| self.ns.resolve(Some(p)).map(String::from)) + }; + ( + attr_local.to_string(), + attr_value.clone(), + attr_prefix.map(String::from), + attr_ns, + ) + }) + .collect(); + + // Fire start_element + self.handler + .start_element(local_name, prefix, elem_ns.as_deref(), &attributes); + + let is_empty = self.input.looking_at(b"/>"); + if is_empty { + self.input.advance(2); + } else { + self.input.expect_byte(b'>')?; + self.parse_content()?; + self.input.expect_str(b"</")?; + let end_name = self.input.parse_name()?; + if end_name != name { + if self.options.recover { + self.input.push_diagnostic( + crate::error::ErrorSeverity::Error, + format!("mismatched end tag: expected </{name}>, found </{end_name}>"), + ); + } else { + return Err(self.input.fatal(format!( + "mismatched end tag: expected </{name}>, found </{end_name}>" + ))); + } + } + self.input.skip_whitespace(); + self.input.expect_byte(b'>')?; + } + + // Fire end_element + self.handler + .end_element(local_name, prefix, elem_ns.as_deref()); + + self.ns.pop_scope(); + self.input.decrement_depth(); + Ok(()) + } + + // --- Content --- + // See XML 1.0 §3.1: [43] content + + fn parse_content(&mut self) -> Result<(), ParseError> { + loop { + if self.input.at_end() { + if self.options.recover { + break; + } + return Err(self + .input + .fatal("unexpected end of input in element content")); + } + if self.input.looking_at(b"</") { + break; + } + if self.input.looking_at(b"<![CDATA[") { + self.parse_cdata()?; + } else if self.input.looking_at(b"<!--") { + self.parse_comment()?; + } else if self.input.looking_at(b"<?") { + self.parse_processing_instruction()?; + } else if self.input.peek() == Some(b'<') { + self.parse_element()?; + } else { + self.parse_char_data()?; + } + } + Ok(()) + } + + // --- Character Data --- + // See XML 1.0 §2.4: [14] CharData + + fn parse_char_data(&mut self) -> Result<(), ParseError> { + let mut text = String::new(); + while !self.input.at_end() { + // Bulk scan: find the next `<`, `&`, or `]]>` boundary + let safe_len = self.input.scan_char_data(); + if safe_len > 0 { + let start = self.input.pos(); + let chunk_bytes = self.input.slice(start, start + safe_len); + if chunk_bytes.contains(&b'\r') { + let chunk = std::str::from_utf8(chunk_bytes) + .map_err(|_| self.input.fatal("invalid UTF-8 in character data"))?; + let mut chars = chunk.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\r' { + if chars.peek() == Some(&'\n') { + chars.next(); + } + text.push('\n'); + } else { + text.push(ch); + } + } + } else { + let chunk = std::str::from_utf8(chunk_bytes) + .map_err(|_| self.input.fatal("invalid UTF-8 in character data"))?; + text.push_str(chunk); + } + self.input.advance_counting_lines(safe_len); + continue; + } + + if self.input.peek() == Some(b'<') { + break; + } + + // XML 1.0 §2.4: "]]>" is forbidden in character data + if self.input.looking_at(b"]]>") { + if self.options.recover { + self.input.push_diagnostic( + ErrorSeverity::Error, + "']]>' not allowed in character data".to_string(), + ); + text.push_str("]]>"); + self.input.advance(3); + continue; + } + return Err(self.input.fatal("']]>' not allowed in character data")); + } + + if self.input.peek() == Some(b'&') { + self.input.parse_reference_into(&mut text)?; + } else { + let ch = self.input.next_char()?; + text.push(ch); + } + } + if !text.is_empty() { + if self.options.no_blanks && text.chars().all(char::is_whitespace) { + return Ok(()); + } + self.handler.characters(&text); + } + Ok(()) + } + + // --- Comments --- + // See XML 1.0 §2.5: [15] Comment + + fn parse_comment(&mut self) -> Result<(), ParseError> { + let content = parse_comment_content(&mut self.input)?; + self.handler.comment(&content); + Ok(()) + } + + // --- CDATA Sections --- + // See XML 1.0 §2.7: [18] CDSect + + fn parse_cdata(&mut self) -> Result<(), ParseError> { + let content = parse_cdata_content(&mut self.input)?; + self.handler.cdata(&content); + Ok(()) + } + + // --- Processing Instructions --- + // See XML 1.0 §2.6: [16] PI + + fn parse_processing_instruction(&mut self) -> Result<(), ParseError> { + let (target, data) = parse_pi_content(&mut self.input)?; + self.handler + .processing_instruction(&target, data.as_deref()); + Ok(()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + // --- Test handler that records events --- + + #[derive(Debug, Default)] + struct RecordingHandler { + events: Vec<String>, + } + + impl SaxHandler for RecordingHandler { + fn start_document(&mut self) { + self.events.push("start_document".to_string()); + } + + fn end_document(&mut self) { + self.events.push("end_document".to_string()); + } + + fn start_element( + &mut self, + local_name: &str, + prefix: Option<&str>, + namespace: Option<&str>, + attributes: &[(String, String, Option<String>, Option<String>)], + ) { + use std::fmt::Write; + let mut event = format!("start_element({local_name}"); + if let Some(pfx) = prefix { + let _ = write!(event, ", prefix={pfx}"); + } + if let Some(ns) = namespace { + let _ = write!(event, ", ns={ns}"); + } + for (name, value, _, _) in attributes { + let _ = write!(event, ", {name}={value}"); + } + event.push(')'); + self.events.push(event); + } + + fn end_element( + &mut self, + local_name: &str, + prefix: Option<&str>, + _namespace: Option<&str>, + ) { + let event = match prefix { + Some(pfx) => format!("end_element({pfx}:{local_name})"), + None => format!("end_element({local_name})"), + }; + self.events.push(event); + } + + fn characters(&mut self, content: &str) { + self.events.push(format!("characters({content})")); + } + + fn cdata(&mut self, content: &str) { + self.events.push(format!("cdata({content})")); + } + + fn comment(&mut self, content: &str) { + self.events.push(format!("comment({content})")); + } + + fn processing_instruction(&mut self, target: &str, data: Option<&str>) { + match data { + Some(d) => self.events.push(format!("pi({target}, {d})")), + None => self.events.push(format!("pi({target})")), + } + } + + fn warning(&mut self, message: &str, _location: SourceLocation) { + self.events.push(format!("warning({message})")); + } + + fn error(&mut self, message: &str, _location: SourceLocation) { + self.events.push(format!("error({message})")); + } + } + + fn parse_events(input: &str) -> Vec<String> { + let mut handler = RecordingHandler::default(); + parse_sax(input, &ParseOptions::default(), &mut handler).unwrap(); + handler.events + } + + #[test] + fn test_sax_empty_element() { + let events = parse_events("<root/>"); + assert_eq!( + events, + vec![ + "start_document", + "start_element(root)", + "end_element(root)", + "end_document", + ] + ); + } + + #[test] + fn test_sax_element_with_text() { + let events = parse_events("<root>Hello</root>"); + assert_eq!( + events, + vec![ + "start_document", + "start_element(root)", + "characters(Hello)", + "end_element(root)", + "end_document", + ] + ); + } + + #[test] + fn test_sax_nested_elements() { + let events = parse_events("<a><b>text</b></a>"); + assert_eq!( + events, + vec![ + "start_document", + "start_element(a)", + "start_element(b)", + "characters(text)", + "end_element(b)", + "end_element(a)", + "end_document", + ] + ); + } + + #[test] + fn test_sax_attributes() { + let events = parse_events("<root id=\"1\" class=\"big\"/>"); + assert_eq!( + events, + vec![ + "start_document", + "start_element(root, id=1, class=big)", + "end_element(root)", + "end_document", + ] + ); + } + + #[test] + fn test_sax_comment() { + let events = parse_events("<root><!-- hello --></root>"); + assert_eq!( + events, + vec![ + "start_document", + "start_element(root)", + "comment( hello )", + "end_element(root)", + "end_document", + ] + ); + } + + #[test] + fn test_sax_cdata() { + let events = parse_events("<root><![CDATA[raw & data]]></root>"); + assert_eq!( + events, + vec![ + "start_document", + "start_element(root)", + "cdata(raw & data)", + "end_element(root)", + "end_document", + ] + ); + } + + #[test] + fn test_sax_processing_instruction() { + let events = parse_events("<?target data?><root/>"); + assert_eq!( + events, + vec![ + "start_document", + "pi(target, data)", + "start_element(root)", + "end_element(root)", + "end_document", + ] + ); + } + + #[test] + fn test_sax_entity_references() { + let events = parse_events("<root>&amp;&lt;&gt;</root>"); + assert_eq!( + events, + vec![ + "start_document", + "start_element(root)", + "characters(&<>)", + "end_element(root)", + "end_document", + ] + ); + } + + #[test] + fn test_sax_mixed_content() { + let events = parse_events("<p>Hello <b>world</b>!</p>"); + assert_eq!( + events, + vec![ + "start_document", + "start_element(p)", + "characters(Hello )", + "start_element(b)", + "characters(world)", + "end_element(b)", + "characters(!)", + "end_element(p)", + "end_document", + ] + ); + } + + #[test] + fn test_sax_namespace() { + let events = parse_events("<root xmlns=\"http://example.com\"><child/></root>"); + assert_eq!( + events, + vec![ + "start_document", + "start_element(root, ns=http://example.com, xmlns=http://example.com)", + "start_element(child, ns=http://example.com)", + "end_element(child)", + "end_element(root)", + "end_document", + ] + ); + } + + #[test] + fn test_sax_prefixed_namespace() { + let events = parse_events("<ns:root xmlns:ns=\"http://example.com\"/>"); + assert_eq!( + events, + vec![ + "start_document", + "start_element(root, prefix=ns, ns=http://example.com, ns=http://example.com)", + "end_element(ns:root)", + "end_document", + ] + ); + } + + #[test] + fn test_sax_doctype() { + // DOCTYPE should be silently skipped, not cause an error + let events = parse_events("<!DOCTYPE html><html/>"); + assert_eq!( + events, + vec![ + "start_document", + "start_element(html)", + "end_element(html)", + "end_document", + ] + ); + } + + #[test] + fn test_sax_xml_declaration() { + let events = parse_events("<?xml version=\"1.0\" encoding=\"UTF-8\"?><root/>"); + assert_eq!( + events, + vec![ + "start_document", + "start_element(root)", + "end_element(root)", + "end_document", + ] + ); + } + + #[test] + fn test_sax_element_count() { + struct Counter { + count: usize, + } + impl SaxHandler for Counter { + fn start_element( + &mut self, + _local_name: &str, + _prefix: Option<&str>, + _namespace: Option<&str>, + _attributes: &[(String, String, Option<String>, Option<String>)], + ) { + self.count += 1; + } + } + + let mut counter = Counter { count: 0 }; + parse_sax( + "<root><a/><b><c/></b><d/></root>", + &ParseOptions::default(), + &mut counter, + ) + .unwrap(); + assert_eq!(counter.count, 5); + } + + #[test] + fn test_sax_text_extraction() { + struct TextCollector { + text: String, + } + impl SaxHandler for TextCollector { + fn characters(&mut self, content: &str) { + self.text.push_str(content); + } + } + + let mut collector = TextCollector { + text: String::new(), + }; + parse_sax( + "<root>Hello <b>world</b>!</root>", + &ParseOptions::default(), + &mut collector, + ) + .unwrap(); + assert_eq!(collector.text, "Hello world!"); + } + + #[test] + fn test_sax_default_handler() { + // DefaultHandler should just work without panicking + let mut handler = DefaultHandler; + parse_sax( + "<root><child/></root>", + &ParseOptions::default(), + &mut handler, + ) + .unwrap(); + } + + #[test] + fn test_sax_error_mismatched_tags() { + let mut handler = DefaultHandler; + let result = parse_sax("<a></b>", &ParseOptions::default(), &mut handler); + assert!(result.is_err()); + } +} diff --git a/browser/vendor/xmloxide/src/serde_xml/de.rs b/browser/vendor/xmloxide/src/serde_xml/de.rs new file mode 100644 index 000000000..d1b1fe180 --- /dev/null +++ b/browser/vendor/xmloxide/src/serde_xml/de.rs @@ -0,0 +1,613 @@ +//! XML Deserializer backed by the xmloxide DOM tree. + +use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor}; +use serde::Deserialize; + +use crate::tree::{Document, NodeId, NodeKind}; + +use super::Error; + +/// Deserializes a Rust value from an XML string. +/// +/// The root element maps to the top-level struct. +/// +/// # Errors +/// +/// Returns an error if parsing or deserialization fails. +pub fn from_str<'de, T: Deserialize<'de>>(xml: &str) -> Result<T, Error> { + let doc = Document::parse_str(xml)?; + let root = doc + .root_element() + .ok_or_else(|| Error::Message("no root element".to_string()))?; + let de = Deserializer::new(&doc, root); + T::deserialize(de) +} + +/// An XML element deserializer. +struct Deserializer<'a> { + doc: &'a Document, + node: NodeId, +} + +impl<'a> Deserializer<'a> { + fn new(doc: &'a Document, node: NodeId) -> Self { + Self { doc, node } + } + + /// Collects the text content of this element (concatenating child text nodes). + fn text_content(&self) -> String { + self.doc.text_content(self.node) + } +} + +impl<'de> de::Deserializer<'de> for Deserializer<'_> { + type Error = Error; + + fn deserialize_any<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + self.deserialize_map(visitor) + } + + fn deserialize_bool<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + let text = self.text_content(); + match text.as_str() { + "true" | "1" => visitor.visit_bool(true), + "false" | "0" => visitor.visit_bool(false), + _ => Err(Error::Message(format!("invalid bool: {text}"))), + } + } + + fn deserialize_i8<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + let text = self.text_content(); + visitor.visit_i8( + text.parse() + .map_err(|_| Error::Message(format!("invalid i8: {text}")))?, + ) + } + + fn deserialize_i16<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + let text = self.text_content(); + visitor.visit_i16( + text.parse() + .map_err(|_| Error::Message(format!("invalid i16: {text}")))?, + ) + } + + fn deserialize_i32<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + let text = self.text_content(); + visitor.visit_i32( + text.parse() + .map_err(|_| Error::Message(format!("invalid i32: {text}")))?, + ) + } + + fn deserialize_i64<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + let text = self.text_content(); + visitor.visit_i64( + text.parse() + .map_err(|_| Error::Message(format!("invalid i64: {text}")))?, + ) + } + + fn deserialize_u8<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + let text = self.text_content(); + visitor.visit_u8( + text.parse() + .map_err(|_| Error::Message(format!("invalid u8: {text}")))?, + ) + } + + fn deserialize_u16<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + let text = self.text_content(); + visitor.visit_u16( + text.parse() + .map_err(|_| Error::Message(format!("invalid u16: {text}")))?, + ) + } + + fn deserialize_u32<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + let text = self.text_content(); + visitor.visit_u32( + text.parse() + .map_err(|_| Error::Message(format!("invalid u32: {text}")))?, + ) + } + + fn deserialize_u64<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + let text = self.text_content(); + visitor.visit_u64( + text.parse() + .map_err(|_| Error::Message(format!("invalid u64: {text}")))?, + ) + } + + fn deserialize_f32<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + let text = self.text_content(); + visitor.visit_f32( + text.parse() + .map_err(|_| Error::Message(format!("invalid f32: {text}")))?, + ) + } + + fn deserialize_f64<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + let text = self.text_content(); + visitor.visit_f64( + text.parse() + .map_err(|_| Error::Message(format!("invalid f64: {text}")))?, + ) + } + + fn deserialize_char<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + let text = self.text_content(); + let mut chars = text.chars(); + let c = chars + .next() + .ok_or_else(|| Error::Message("empty char".to_string()))?; + if chars.next().is_some() { + return Err(Error::Message(format!("expected single char, got: {text}"))); + } + visitor.visit_char(c) + } + + fn deserialize_str<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + visitor.visit_string(self.text_content()) + } + + fn deserialize_string<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + visitor.visit_string(self.text_content()) + } + + fn deserialize_bytes<V: Visitor<'de>>(self, _visitor: V) -> Result<V::Value, Self::Error> { + Err(Error::Message("bytes not supported in XML".to_string())) + } + + fn deserialize_byte_buf<V: Visitor<'de>>(self, _visitor: V) -> Result<V::Value, Self::Error> { + Err(Error::Message("byte_buf not supported in XML".to_string())) + } + + fn deserialize_option<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + visitor.visit_some(self) + } + + fn deserialize_unit<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + visitor.visit_unit() + } + + fn deserialize_unit_struct<V: Visitor<'de>>( + self, + _name: &'static str, + visitor: V, + ) -> Result<V::Value, Self::Error> { + visitor.visit_unit() + } + + fn deserialize_newtype_struct<V: Visitor<'de>>( + self, + _name: &'static str, + visitor: V, + ) -> Result<V::Value, Self::Error> { + visitor.visit_newtype_struct(self) + } + + fn deserialize_seq<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + let children: Vec<NodeId> = self + .doc + .children(self.node) + .filter(|&c| matches!(self.doc.node(c).kind, NodeKind::Element { .. })) + .collect(); + visitor.visit_seq(SeqDeserializer { + doc: self.doc, + children, + index: 0, + }) + } + + fn deserialize_tuple<V: Visitor<'de>>( + self, + _len: usize, + visitor: V, + ) -> Result<V::Value, Self::Error> { + self.deserialize_seq(visitor) + } + + fn deserialize_tuple_struct<V: Visitor<'de>>( + self, + _name: &'static str, + _len: usize, + visitor: V, + ) -> Result<V::Value, Self::Error> { + self.deserialize_seq(visitor) + } + + fn deserialize_map<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + visitor.visit_map(ElementMapAccess::new(self.doc, self.node)) + } + + fn deserialize_struct<V: Visitor<'de>>( + self, + _name: &'static str, + _fields: &'static [&'static str], + visitor: V, + ) -> Result<V::Value, Self::Error> { + self.deserialize_map(visitor) + } + + fn deserialize_enum<V: Visitor<'de>>( + self, + _name: &'static str, + _variants: &'static [&'static str], + visitor: V, + ) -> Result<V::Value, Self::Error> { + let text = self.text_content(); + visitor.visit_enum(StringIntoDeserializer(text)) + } + + fn deserialize_identifier<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + self.deserialize_string(visitor) + } + + fn deserialize_ignored_any<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + visitor.visit_unit() + } +} + +/// Map access over an XML element: yields attributes (as `$attr:name`) and child elements. +struct ElementMapAccess<'a> { + doc: &'a Document, + node: NodeId, + attrs: Vec<(String, String)>, + children: Vec<(String, Vec<NodeId>)>, + index: usize, + keys: Vec<String>, +} + +impl<'a> ElementMapAccess<'a> { + fn new(doc: &'a Document, node: NodeId) -> Self { + let attrs: Vec<(String, String)> = doc + .attributes(node) + .iter() + .map(|a| (format!("$attr:{}", a.name), a.value.clone())) + .collect(); + + let mut child_map: Vec<(String, Vec<NodeId>)> = Vec::new(); + for child_id in doc.children(node) { + if let NodeKind::Element { ref name, .. } = doc.node(child_id).kind { + if let Some(entry) = child_map.iter_mut().find(|(n, _)| n == name) { + entry.1.push(child_id); + } else { + child_map.push((name.clone(), vec![child_id])); + } + } + } + + let has_text = doc + .children(node) + .any(|c| matches!(doc.node(c).kind, NodeKind::Text { .. })); + + let mut keys: Vec<String> = attrs.iter().map(|(k, _)| k.clone()).collect(); + if has_text { + keys.push("$text".to_string()); + } + for (name, _) in &child_map { + keys.push(name.clone()); + } + + Self { + doc, + node, + attrs, + children: child_map, + index: 0, + keys, + } + } +} + +impl<'de> MapAccess<'de> for ElementMapAccess<'_> { + type Error = Error; + + fn next_key_seed<K: DeserializeSeed<'de>>( + &mut self, + seed: K, + ) -> Result<Option<K::Value>, Self::Error> { + if self.index >= self.keys.len() { + return Ok(None); + } + let key = &self.keys[self.index]; + seed.deserialize(de::value::StrDeserializer::new(key)) + .map(Some) + } + + fn next_value_seed<V: DeserializeSeed<'de>>( + &mut self, + seed: V, + ) -> Result<V::Value, Self::Error> { + let key = &self.keys[self.index]; + self.index += 1; + + if let Some(attr) = self.attrs.iter().find(|(k, _)| k == key) { + return seed.deserialize(de::value::StringDeserializer::new(attr.1.clone())); + } + + if key == "$text" { + let text = self.doc.text_content(self.node); + return seed.deserialize(de::value::StringDeserializer::new(text)); + } + + if let Some(entry) = self.children.iter().find(|(n, _)| n == key) { + let nodes = &entry.1; + if nodes.len() == 1 { + return seed.deserialize(Deserializer::new(self.doc, nodes[0])); + } + return seed.deserialize(SeqNodeDeserializer { + doc: self.doc, + nodes: nodes.clone(), + }); + } + + Err(Error::Message(format!("unexpected key: {key}"))) + } +} + +/// Deserializer that presents multiple nodes as a sequence. +struct SeqNodeDeserializer<'a> { + doc: &'a Document, + nodes: Vec<NodeId>, +} + +impl<'de> de::Deserializer<'de> for SeqNodeDeserializer<'_> { + type Error = Error; + + fn deserialize_any<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + self.deserialize_seq(visitor) + } + + fn deserialize_seq<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + visitor.visit_seq(SeqDeserializer { + doc: self.doc, + children: self.nodes, + index: 0, + }) + } + + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 u8 u16 u32 u64 f32 f64 char str string bytes + byte_buf option unit unit_struct newtype_struct tuple tuple_struct + map struct enum identifier ignored_any + } +} + +/// Sequential access over a list of child nodes. +struct SeqDeserializer<'a> { + doc: &'a Document, + children: Vec<NodeId>, + index: usize, +} + +impl<'de> SeqAccess<'de> for SeqDeserializer<'_> { + type Error = Error; + + fn next_element_seed<T: DeserializeSeed<'de>>( + &mut self, + seed: T, + ) -> Result<Option<T::Value>, Self::Error> { + if self.index >= self.children.len() { + return Ok(None); + } + let node = self.children[self.index]; + self.index += 1; + seed.deserialize(Deserializer::new(self.doc, node)) + .map(Some) + } +} + +/// Helper: wraps a `String` as a serde enum deserializer for simple string enums. +struct StringEnumDeserializer(String); + +impl<'de> de::Deserializer<'de> for StringEnumDeserializer { + type Error = Error; + + fn deserialize_any<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { + visitor.visit_string(self.0) + } + + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 u8 u16 u32 u64 f32 f64 char str string bytes + byte_buf option unit unit_struct newtype_struct seq tuple tuple_struct + map struct enum identifier ignored_any + } +} + +/// Newtype for using `String` as an `EnumAccess` for unit variants. +struct StringIntoDeserializer(String); + +impl<'de> de::EnumAccess<'de> for StringIntoDeserializer { + type Error = Error; + type Variant = UnitVariantAccess; + + fn variant_seed<V: DeserializeSeed<'de>>( + self, + seed: V, + ) -> Result<(V::Value, Self::Variant), Self::Error> { + let val = seed.deserialize(StringEnumDeserializer(self.0))?; + Ok((val, UnitVariantAccess)) + } +} + +/// Unit variant access (no data associated with the variant). +struct UnitVariantAccess; + +impl<'de> de::VariantAccess<'de> for UnitVariantAccess { + type Error = Error; + + fn unit_variant(self) -> Result<(), Self::Error> { + Ok(()) + } + + fn newtype_variant_seed<T: DeserializeSeed<'de>>( + self, + _seed: T, + ) -> Result<T::Value, Self::Error> { + Err(Error::Message("newtype variant not supported".to_string())) + } + + fn tuple_variant<V: Visitor<'de>>( + self, + _len: usize, + _visitor: V, + ) -> Result<V::Value, Self::Error> { + Err(Error::Message("tuple variant not supported".to_string())) + } + + fn struct_variant<V: Visitor<'de>>( + self, + _fields: &'static [&'static str], + _visitor: V, + ) -> Result<V::Value, Self::Error> { + Err(Error::Message("struct variant not supported".to_string())) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use serde::Deserialize; + + #[test] + fn test_de_simple_struct() { + #[derive(Debug, Deserialize, PartialEq)] + struct Root { + name: String, + value: String, + } + let xml = "<Root><name>hello</name><value>world</value></Root>"; + let r: Root = from_str(xml).unwrap(); + assert_eq!(r.name, "hello"); + assert_eq!(r.value, "world"); + } + + #[test] + fn test_de_attributes() { + #[derive(Debug, Deserialize, PartialEq)] + struct Item { + #[serde(rename = "$attr:id")] + id: String, + #[serde(rename = "$attr:class")] + class: String, + } + let xml = r#"<Item id="1" class="foo"/>"#; + let item: Item = from_str(xml).unwrap(); + assert_eq!(item.id, "1"); + assert_eq!(item.class, "foo"); + } + + #[test] + fn test_de_text_content() { + #[derive(Debug, Deserialize, PartialEq)] + struct Msg { + #[serde(rename = "$text")] + text: String, + } + let xml = "<Msg>Hello World</Msg>"; + let msg: Msg = from_str(xml).unwrap(); + assert_eq!(msg.text, "Hello World"); + } + + #[test] + fn test_de_nested() { + #[derive(Debug, Deserialize, PartialEq)] + struct Inner { + #[serde(rename = "$text")] + text: String, + } + #[derive(Debug, Deserialize, PartialEq)] + struct Outer { + inner: Inner, + } + let xml = "<Outer><inner>data</inner></Outer>"; + let o: Outer = from_str(xml).unwrap(); + assert_eq!(o.inner.text, "data"); + } + + #[test] + fn test_de_sequence() { + #[derive(Debug, Deserialize, PartialEq)] + struct Item { + #[serde(rename = "$text")] + text: String, + } + #[derive(Debug, Deserialize, PartialEq)] + struct List { + item: Vec<Item>, + } + let xml = "<List><item>A</item><item>B</item><item>C</item></List>"; + let list: List = from_str(xml).unwrap(); + assert_eq!(list.item.len(), 3); + assert_eq!(list.item[0].text, "A"); + assert_eq!(list.item[2].text, "C"); + } + + #[test] + fn test_de_numeric() { + #[derive(Debug, Deserialize, PartialEq)] + struct Data { + count: u32, + ratio: f64, + } + let xml = "<Data><count>42</count><ratio>2.72</ratio></Data>"; + let d: Data = from_str(xml).unwrap(); + assert_eq!(d.count, 42); + assert!((d.ratio - 2.72).abs() < f64::EPSILON); + } + + #[test] + fn test_de_bool() { + #[derive(Debug, Deserialize, PartialEq)] + struct Flags { + active: bool, + visible: bool, + } + let xml = "<Flags><active>true</active><visible>false</visible></Flags>"; + let f: Flags = from_str(xml).unwrap(); + assert!(f.active); + assert!(!f.visible); + } + + #[test] + fn test_de_option_present() { + #[derive(Debug, Deserialize, PartialEq)] + struct Data { + #[serde(default)] + value: Option<String>, + } + let xml = "<Data><value>yes</value></Data>"; + let d: Data = from_str(xml).unwrap(); + assert_eq!(d.value, Some("yes".to_string())); + } + + #[test] + fn test_de_mixed_attrs_and_children() { + #[derive(Debug, Deserialize, PartialEq)] + struct Node { + #[serde(rename = "$attr:type")] + node_type: String, + child: String, + } + let xml = r#"<Node type="special"><child>data</child></Node>"#; + let n: Node = from_str(xml).unwrap(); + assert_eq!(n.node_type, "special"); + assert_eq!(n.child, "data"); + } + + #[test] + fn test_de_renamed_root() { + #[derive(Debug, Deserialize, PartialEq)] + #[serde(rename = "book")] + struct Book { + title: String, + } + let xml = "<book><title>Rust in Action</title></book>"; + let b: Book = from_str(xml).unwrap(); + assert_eq!(b.title, "Rust in Action"); + } +} diff --git a/browser/vendor/xmloxide/src/serde_xml/error.rs b/browser/vendor/xmloxide/src/serde_xml/error.rs new file mode 100644 index 000000000..803aa2fdf --- /dev/null +++ b/browser/vendor/xmloxide/src/serde_xml/error.rs @@ -0,0 +1,41 @@ +//! Serde error type for XML (de)serialization. + +use std::fmt; + +/// Error type for serde XML operations. +#[derive(Debug)] +pub enum Error { + /// A serde serialization/deserialization error. + Message(String), + /// An XML parsing error. + Parse(crate::error::ParseError), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Message(msg) => write!(f, "{msg}"), + Self::Parse(e) => write!(f, "XML parse error: {e}"), + } + } +} + +impl std::error::Error for Error {} + +impl serde::de::Error for Error { + fn custom<T: fmt::Display>(msg: T) -> Self { + Self::Message(msg.to_string()) + } +} + +impl serde::ser::Error for Error { + fn custom<T: fmt::Display>(msg: T) -> Self { + Self::Message(msg.to_string()) + } +} + +impl From<crate::error::ParseError> for Error { + fn from(e: crate::error::ParseError) -> Self { + Self::Parse(e) + } +} diff --git a/browser/vendor/xmloxide/src/serde_xml/mod.rs b/browser/vendor/xmloxide/src/serde_xml/mod.rs new file mode 100644 index 000000000..8a412ca9e --- /dev/null +++ b/browser/vendor/xmloxide/src/serde_xml/mod.rs @@ -0,0 +1,46 @@ +//! Serde XML (de)serialization. +//! +//! This module provides `from_str` / `to_string` functions for converting between +//! XML text and Rust types via serde. It requires the `serde` feature. +//! +//! # Conventions +//! +//! - Element children map to struct fields by tag name +//! - Attributes are accessed via the `$attr` prefix: `#[serde(rename = "$attr:class")]` +//! - Text content is accessed via `$text`: `#[serde(rename = "$text")]` +//! - Sequences (repeated elements) are collected into `Vec<T>` +//! - The root element name is used as the struct name (or overridden via `#[serde(rename)]`) +//! +//! # Examples +//! +//! ``` +//! # #[cfg(feature = "serde")] +//! # { +//! use serde::{Deserialize, Serialize}; +//! +//! #[derive(Debug, Deserialize, Serialize, PartialEq)] +//! #[serde(rename = "book")] +//! struct Book { +//! #[serde(rename = "$attr:isbn")] +//! isbn: String, +//! title: String, +//! author: String, +//! } +//! +//! let xml = r#"<book isbn="978-0"><title>Rust</title><author>Alice</author></book>"#; +//! let book: Book = xmloxide::serde_xml::from_str(xml).unwrap(); +//! assert_eq!(book.isbn, "978-0"); +//! assert_eq!(book.title, "Rust"); +//! +//! let xml_out = xmloxide::serde_xml::to_string(&book).unwrap(); +//! assert!(xml_out.contains("<title>Rust</title>")); +//! # } +//! ``` + +mod de; +mod error; +mod ser; + +pub use de::from_str; +pub use error::Error; +pub use ser::to_string; diff --git a/browser/vendor/xmloxide/src/serde_xml/ser.rs b/browser/vendor/xmloxide/src/serde_xml/ser.rs new file mode 100644 index 000000000..e80008fcd --- /dev/null +++ b/browser/vendor/xmloxide/src/serde_xml/ser.rs @@ -0,0 +1,887 @@ +//! XML Serializer that produces XML strings from Rust types via serde. + +use serde::ser::{self, Serialize}; + +use super::Error; + +/// Serializes a Rust value to an XML string. +/// +/// The struct's name (or `#[serde(rename = "...")]`) becomes the root element. +/// Fields prefixed with `$attr:` become attributes. A field named `$text` +/// becomes the element's text content. +/// +/// # Errors +/// +/// Returns an error if serialization fails. +pub fn to_string<T: Serialize>(value: &T) -> Result<String, Error> { + let mut output = String::new(); + let serializer = XmlSerializer { + output: &mut output, + }; + value.serialize(serializer)?; + Ok(output) +} + +struct XmlSerializer<'a> { + output: &'a mut String, +} + +impl<'a> ser::Serializer for XmlSerializer<'a> { + type Ok = (); + type Error = Error; + type SerializeSeq = SeqSerializer<'a>; + type SerializeTuple = SeqSerializer<'a>; + type SerializeTupleStruct = SeqSerializer<'a>; + type SerializeTupleVariant = SeqSerializer<'a>; + type SerializeMap = MapSerializer<'a>; + type SerializeStruct = StructSerializer<'a>; + type SerializeStructVariant = StructSerializer<'a>; + + fn serialize_bool(self, v: bool) -> Result<Self::Ok, Self::Error> { + self.output.push_str(if v { "true" } else { "false" }); + Ok(()) + } + + fn serialize_i8(self, v: i8) -> Result<Self::Ok, Self::Error> { + self.output.push_str(&v.to_string()); + Ok(()) + } + + fn serialize_i16(self, v: i16) -> Result<Self::Ok, Self::Error> { + self.output.push_str(&v.to_string()); + Ok(()) + } + + fn serialize_i32(self, v: i32) -> Result<Self::Ok, Self::Error> { + self.output.push_str(&v.to_string()); + Ok(()) + } + + fn serialize_i64(self, v: i64) -> Result<Self::Ok, Self::Error> { + self.output.push_str(&v.to_string()); + Ok(()) + } + + fn serialize_u8(self, v: u8) -> Result<Self::Ok, Self::Error> { + self.output.push_str(&v.to_string()); + Ok(()) + } + + fn serialize_u16(self, v: u16) -> Result<Self::Ok, Self::Error> { + self.output.push_str(&v.to_string()); + Ok(()) + } + + fn serialize_u32(self, v: u32) -> Result<Self::Ok, Self::Error> { + self.output.push_str(&v.to_string()); + Ok(()) + } + + fn serialize_u64(self, v: u64) -> Result<Self::Ok, Self::Error> { + self.output.push_str(&v.to_string()); + Ok(()) + } + + fn serialize_f32(self, v: f32) -> Result<Self::Ok, Self::Error> { + self.output.push_str(&v.to_string()); + Ok(()) + } + + fn serialize_f64(self, v: f64) -> Result<Self::Ok, Self::Error> { + self.output.push_str(&v.to_string()); + Ok(()) + } + + fn serialize_char(self, v: char) -> Result<Self::Ok, Self::Error> { + escape_xml_to(self.output, &v.to_string()); + Ok(()) + } + + fn serialize_str(self, v: &str) -> Result<Self::Ok, Self::Error> { + escape_xml_to(self.output, v); + Ok(()) + } + + fn serialize_bytes(self, _v: &[u8]) -> Result<Self::Ok, Self::Error> { + Err(Error::Message("bytes not supported in XML".to_string())) + } + + fn serialize_none(self) -> Result<Self::Ok, Self::Error> { + Ok(()) + } + + fn serialize_some<T: ?Sized + Serialize>(self, value: &T) -> Result<Self::Ok, Self::Error> { + value.serialize(self) + } + + fn serialize_unit(self) -> Result<Self::Ok, Self::Error> { + Ok(()) + } + + fn serialize_unit_struct(self, _name: &'static str) -> Result<Self::Ok, Self::Error> { + Ok(()) + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + variant: &'static str, + ) -> Result<Self::Ok, Self::Error> { + self.output.push_str(variant); + Ok(()) + } + + fn serialize_newtype_struct<T: ?Sized + Serialize>( + self, + _name: &'static str, + value: &T, + ) -> Result<Self::Ok, Self::Error> { + value.serialize(self) + } + + fn serialize_newtype_variant<T: ?Sized + Serialize>( + self, + _name: &'static str, + _variant_index: u32, + variant: &'static str, + value: &T, + ) -> Result<Self::Ok, Self::Error> { + self.output.push('<'); + self.output.push_str(variant); + self.output.push('>'); + value.serialize(XmlSerializer { + output: self.output, + })?; + self.output.push_str("</"); + self.output.push_str(variant); + self.output.push('>'); + Ok(()) + } + + fn serialize_seq(self, _len: Option<usize>) -> Result<Self::SerializeSeq, Self::Error> { + Ok(SeqSerializer { + output: self.output, + }) + } + + fn serialize_tuple(self, _len: usize) -> Result<Self::SerializeTuple, Self::Error> { + Ok(SeqSerializer { + output: self.output, + }) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result<Self::SerializeTupleStruct, Self::Error> { + Ok(SeqSerializer { + output: self.output, + }) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result<Self::SerializeTupleVariant, Self::Error> { + Ok(SeqSerializer { + output: self.output, + }) + } + + fn serialize_map(self, _len: Option<usize>) -> Result<Self::SerializeMap, Self::Error> { + Ok(MapSerializer { + output: self.output, + current_key: None, + }) + } + + fn serialize_struct( + self, + name: &'static str, + _len: usize, + ) -> Result<Self::SerializeStruct, Self::Error> { + Ok(StructSerializer { + output: self.output, + tag: name.to_string(), + attrs: String::new(), + body: String::new(), + }) + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + variant: &'static str, + _len: usize, + ) -> Result<Self::SerializeStructVariant, Self::Error> { + Ok(StructSerializer { + output: self.output, + tag: variant.to_string(), + attrs: String::new(), + body: String::new(), + }) + } +} + +/// Serializer for struct fields — collects attrs and child elements, then emits XML. +struct StructSerializer<'a> { + output: &'a mut String, + tag: String, + attrs: String, + body: String, +} + +impl ser::SerializeStruct for StructSerializer<'_> { + type Ok = (); + type Error = Error; + + fn serialize_field<T: ?Sized + Serialize>( + &mut self, + key: &'static str, + value: &T, + ) -> Result<(), Self::Error> { + if let Some(attr_name) = key.strip_prefix("$attr:") { + let mut val_str = String::new(); + value.serialize(XmlSerializer { + output: &mut val_str, + })?; + self.attrs.push(' '); + self.attrs.push_str(attr_name); + self.attrs.push_str("=\""); + escape_xml_attr_to(&mut self.attrs, &val_str); + self.attrs.push('"'); + } else if key == "$text" { + value.serialize(XmlSerializer { + output: &mut self.body, + })?; + } else { + let mut child_buf = String::new(); + value.serialize(FieldSerializer { + output: &mut child_buf, + tag: key, + })?; + self.body.push_str(&child_buf); + } + Ok(()) + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + self.output.push('<'); + self.output.push_str(&self.tag); + self.output.push_str(&self.attrs); + if self.body.is_empty() { + self.output.push_str("/>"); + } else { + self.output.push('>'); + self.output.push_str(&self.body); + self.output.push_str("</"); + self.output.push_str(&self.tag); + self.output.push('>'); + } + Ok(()) + } +} + +impl ser::SerializeStructVariant for StructSerializer<'_> { + type Ok = (); + type Error = Error; + + fn serialize_field<T: ?Sized + Serialize>( + &mut self, + key: &'static str, + value: &T, + ) -> Result<(), Self::Error> { + ser::SerializeStruct::serialize_field(self, key, value) + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + ser::SerializeStruct::end(self) + } +} + +/// Serializer for a struct field that wraps scalar values in `<tag>...</tag>`. +/// For sequences (`Vec`), each element gets its own `<tag>` wrapper. +struct FieldSerializer<'a> { + output: &'a mut String, + tag: &'a str, +} + +impl FieldSerializer<'_> { + fn wrap_scalar(self, value: &str) { + self.output.push('<'); + self.output.push_str(self.tag); + self.output.push('>'); + self.output.push_str(value); + self.output.push_str("</"); + self.output.push_str(self.tag); + self.output.push('>'); + } + + fn wrap_scalar_escaped(self, value: &str) { + self.output.push('<'); + self.output.push_str(self.tag); + self.output.push('>'); + escape_xml_to(self.output, value); + self.output.push_str("</"); + self.output.push_str(self.tag); + self.output.push('>'); + } +} + +impl<'a> ser::Serializer for FieldSerializer<'a> { + type Ok = (); + type Error = Error; + type SerializeSeq = SeqFieldSerializer<'a>; + type SerializeTuple = SeqFieldSerializer<'a>; + type SerializeTupleStruct = SeqFieldSerializer<'a>; + type SerializeTupleVariant = SeqFieldSerializer<'a>; + type SerializeMap = MapSerializer<'a>; + type SerializeStruct = StructSerializer<'a>; + type SerializeStructVariant = StructSerializer<'a>; + + fn serialize_bool(self, v: bool) -> Result<Self::Ok, Self::Error> { + self.output.push('<'); + self.output.push_str(self.tag); + self.output.push('>'); + self.output.push_str(if v { "true" } else { "false" }); + self.output.push_str("</"); + self.output.push_str(self.tag); + self.output.push('>'); + Ok(()) + } + + fn serialize_i8(self, v: i8) -> Result<Self::Ok, Self::Error> { + self.wrap_scalar(&v.to_string()); + Ok(()) + } + fn serialize_i16(self, v: i16) -> Result<Self::Ok, Self::Error> { + self.wrap_scalar(&v.to_string()); + Ok(()) + } + fn serialize_i32(self, v: i32) -> Result<Self::Ok, Self::Error> { + self.wrap_scalar(&v.to_string()); + Ok(()) + } + fn serialize_i64(self, v: i64) -> Result<Self::Ok, Self::Error> { + self.wrap_scalar(&v.to_string()); + Ok(()) + } + fn serialize_u8(self, v: u8) -> Result<Self::Ok, Self::Error> { + self.wrap_scalar(&v.to_string()); + Ok(()) + } + fn serialize_u16(self, v: u16) -> Result<Self::Ok, Self::Error> { + self.wrap_scalar(&v.to_string()); + Ok(()) + } + fn serialize_u32(self, v: u32) -> Result<Self::Ok, Self::Error> { + self.wrap_scalar(&v.to_string()); + Ok(()) + } + fn serialize_u64(self, v: u64) -> Result<Self::Ok, Self::Error> { + self.wrap_scalar(&v.to_string()); + Ok(()) + } + fn serialize_f32(self, v: f32) -> Result<Self::Ok, Self::Error> { + self.wrap_scalar(&v.to_string()); + Ok(()) + } + fn serialize_f64(self, v: f64) -> Result<Self::Ok, Self::Error> { + self.wrap_scalar(&v.to_string()); + Ok(()) + } + + fn serialize_char(self, v: char) -> Result<Self::Ok, Self::Error> { + self.wrap_scalar_escaped(&v.to_string()); + Ok(()) + } + + fn serialize_str(self, v: &str) -> Result<Self::Ok, Self::Error> { + self.wrap_scalar_escaped(v); + Ok(()) + } + + fn serialize_bytes(self, _v: &[u8]) -> Result<Self::Ok, Self::Error> { + Err(Error::Message("bytes not supported".to_string())) + } + + fn serialize_none(self) -> Result<Self::Ok, Self::Error> { + Ok(()) + } + + fn serialize_some<T: ?Sized + Serialize>(self, value: &T) -> Result<Self::Ok, Self::Error> { + value.serialize(self) + } + + fn serialize_unit(self) -> Result<Self::Ok, Self::Error> { + self.output.push('<'); + self.output.push_str(self.tag); + self.output.push_str("/>"); + Ok(()) + } + + fn serialize_unit_struct(self, _name: &'static str) -> Result<Self::Ok, Self::Error> { + self.serialize_unit() + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + variant: &'static str, + ) -> Result<Self::Ok, Self::Error> { + self.wrap_scalar(variant); + Ok(()) + } + + fn serialize_newtype_struct<T: ?Sized + Serialize>( + self, + _name: &'static str, + value: &T, + ) -> Result<Self::Ok, Self::Error> { + value.serialize(self) + } + + fn serialize_newtype_variant<T: ?Sized + Serialize>( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + value: &T, + ) -> Result<Self::Ok, Self::Error> { + value.serialize(self) + } + + fn serialize_seq(self, _len: Option<usize>) -> Result<Self::SerializeSeq, Self::Error> { + Ok(SeqFieldSerializer { + output: self.output, + tag: self.tag, + }) + } + + fn serialize_tuple(self, _len: usize) -> Result<Self::SerializeTuple, Self::Error> { + Ok(SeqFieldSerializer { + output: self.output, + tag: self.tag, + }) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result<Self::SerializeTupleStruct, Self::Error> { + Ok(SeqFieldSerializer { + output: self.output, + tag: self.tag, + }) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result<Self::SerializeTupleVariant, Self::Error> { + Ok(SeqFieldSerializer { + output: self.output, + tag: self.tag, + }) + } + + fn serialize_map(self, _len: Option<usize>) -> Result<Self::SerializeMap, Self::Error> { + Ok(MapSerializer { + output: self.output, + current_key: None, + }) + } + + fn serialize_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result<Self::SerializeStruct, Self::Error> { + Ok(StructSerializer { + output: self.output, + tag: self.tag.to_string(), + attrs: String::new(), + body: String::new(), + }) + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + variant: &'static str, + _len: usize, + ) -> Result<Self::SerializeStructVariant, Self::Error> { + Ok(StructSerializer { + output: self.output, + tag: variant.to_string(), + attrs: String::new(), + body: String::new(), + }) + } +} + +/// Sequence serializer for top-level seq. +struct SeqSerializer<'a> { + output: &'a mut String, +} + +impl ser::SerializeSeq for SeqSerializer<'_> { + type Ok = (); + type Error = Error; + + fn serialize_element<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> { + value.serialize(XmlSerializer { + output: self.output, + }) + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + Ok(()) + } +} + +impl ser::SerializeTuple for SeqSerializer<'_> { + type Ok = (); + type Error = Error; + + fn serialize_element<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> { + ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + ser::SerializeSeq::end(self) + } +} + +impl ser::SerializeTupleStruct for SeqSerializer<'_> { + type Ok = (); + type Error = Error; + + fn serialize_field<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> { + ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + ser::SerializeSeq::end(self) + } +} + +impl ser::SerializeTupleVariant for SeqSerializer<'_> { + type Ok = (); + type Error = Error; + + fn serialize_field<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> { + ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + ser::SerializeSeq::end(self) + } +} + +/// Sequence field serializer: each element gets wrapped in `<tag>...</tag>`. +struct SeqFieldSerializer<'a> { + output: &'a mut String, + tag: &'a str, +} + +impl ser::SerializeSeq for SeqFieldSerializer<'_> { + type Ok = (); + type Error = Error; + + fn serialize_element<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> { + value.serialize(FieldSerializer { + output: self.output, + tag: self.tag, + }) + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + Ok(()) + } +} + +impl ser::SerializeTuple for SeqFieldSerializer<'_> { + type Ok = (); + type Error = Error; + + fn serialize_element<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> { + ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + ser::SerializeSeq::end(self) + } +} + +impl ser::SerializeTupleStruct for SeqFieldSerializer<'_> { + type Ok = (); + type Error = Error; + + fn serialize_field<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> { + ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + ser::SerializeSeq::end(self) + } +} + +impl ser::SerializeTupleVariant for SeqFieldSerializer<'_> { + type Ok = (); + type Error = Error; + + fn serialize_field<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> { + ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + ser::SerializeSeq::end(self) + } +} + +/// Map serializer. +struct MapSerializer<'a> { + output: &'a mut String, + current_key: Option<String>, +} + +impl ser::SerializeMap for MapSerializer<'_> { + type Ok = (); + type Error = Error; + + fn serialize_key<T: ?Sized + Serialize>(&mut self, key: &T) -> Result<(), Self::Error> { + let mut key_str = String::new(); + key.serialize(XmlSerializer { + output: &mut key_str, + })?; + self.current_key = Some(key_str); + Ok(()) + } + + fn serialize_value<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> { + let key = self + .current_key + .take() + .ok_or_else(|| Error::Message("serialize_value called without key".to_string()))?; + self.output.push('<'); + self.output.push_str(&key); + self.output.push('>'); + value.serialize(XmlSerializer { + output: self.output, + })?; + self.output.push_str("</"); + self.output.push_str(&key); + self.output.push('>'); + Ok(()) + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + Ok(()) + } +} + +/// Escape XML special characters for text content. +fn escape_xml_to(output: &mut String, s: &str) { + for c in s.chars() { + match c { + '<' => output.push_str("&lt;"), + '>' => output.push_str("&gt;"), + '&' => output.push_str("&amp;"), + _ => output.push(c), + } + } +} + +/// Escape XML special characters for attribute values. +fn escape_xml_attr_to(output: &mut String, s: &str) { + for c in s.chars() { + match c { + '<' => output.push_str("&lt;"), + '>' => output.push_str("&gt;"), + '&' => output.push_str("&amp;"), + '"' => output.push_str("&quot;"), + _ => output.push(c), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use serde::Serialize; + + #[test] + fn test_ser_simple_struct() { + #[derive(Serialize)] + #[serde(rename = "root")] + struct Root { + name: String, + value: String, + } + let r = Root { + name: "hello".to_string(), + value: "world".to_string(), + }; + let xml = to_string(&r).unwrap(); + assert_eq!(xml, "<root><name>hello</name><value>world</value></root>"); + } + + #[test] + fn test_ser_attributes() { + #[derive(Serialize)] + #[serde(rename = "item")] + struct Item { + #[serde(rename = "$attr:id")] + id: String, + #[serde(rename = "$attr:class")] + class: String, + } + let item = Item { + id: "1".to_string(), + class: "foo".to_string(), + }; + let xml = to_string(&item).unwrap(); + assert_eq!(xml, r#"<item id="1" class="foo"/>"#); + } + + #[test] + fn test_ser_text_content() { + #[derive(Serialize)] + #[serde(rename = "msg")] + struct Msg { + #[serde(rename = "$text")] + text: String, + } + let msg = Msg { + text: "Hello World".to_string(), + }; + let xml = to_string(&msg).unwrap(); + assert_eq!(xml, "<msg>Hello World</msg>"); + } + + #[test] + fn test_ser_sequence() { + #[derive(Serialize)] + #[serde(rename = "item")] + struct Item { + #[serde(rename = "$text")] + text: String, + } + #[derive(Serialize)] + #[serde(rename = "list")] + struct List { + item: Vec<Item>, + } + let list = List { + item: vec![ + Item { + text: "A".to_string(), + }, + Item { + text: "B".to_string(), + }, + ], + }; + let xml = to_string(&list).unwrap(); + assert_eq!(xml, "<list><item>A</item><item>B</item></list>"); + } + + #[test] + fn test_ser_numeric() { + #[derive(Serialize)] + #[serde(rename = "data")] + struct Data { + count: u32, + ratio: f64, + } + let d = Data { + count: 42, + ratio: 2.72, + }; + let xml = to_string(&d).unwrap(); + assert_eq!(xml, "<data><count>42</count><ratio>2.72</ratio></data>"); + } + + #[test] + fn test_ser_escaping() { + #[derive(Serialize)] + #[serde(rename = "msg")] + struct Msg { + #[serde(rename = "$text")] + text: String, + } + let msg = Msg { + text: "<b>&amp;</b>".to_string(), + }; + let xml = to_string(&msg).unwrap(); + assert_eq!(xml, "<msg>&lt;b&gt;&amp;amp;&lt;/b&gt;</msg>"); + } + + #[test] + fn test_ser_attr_escaping() { + #[derive(Serialize)] + #[serde(rename = "item")] + struct Item { + #[serde(rename = "$attr:val")] + val: String, + } + let item = Item { + val: "a\"b".to_string(), + }; + let xml = to_string(&item).unwrap(); + assert_eq!(xml, r#"<item val="a&quot;b"/>"#); + } + + #[test] + fn test_ser_nested() { + #[derive(Serialize)] + #[serde(rename = "inner")] + struct Inner { + #[serde(rename = "$text")] + text: String, + } + #[derive(Serialize)] + #[serde(rename = "outer")] + struct Outer { + inner: Inner, + } + let o = Outer { + inner: Inner { + text: "data".to_string(), + }, + }; + let xml = to_string(&o).unwrap(); + assert_eq!(xml, "<outer><inner>data</inner></outer>"); + } + + #[test] + fn test_ser_none_omitted() { + #[derive(Serialize)] + #[serde(rename = "data")] + struct Data { + #[serde(skip_serializing_if = "Option::is_none")] + value: Option<String>, + name: String, + } + let d = Data { + value: None, + name: "test".to_string(), + }; + let xml = to_string(&d).unwrap(); + assert_eq!(xml, "<data><name>test</name></data>"); + } +} diff --git a/browser/vendor/xmloxide/src/serial/c14n.rs b/browser/vendor/xmloxide/src/serial/c14n.rs new file mode 100644 index 000000000..ddaa02d16 --- /dev/null +++ b/browser/vendor/xmloxide/src/serial/c14n.rs @@ -0,0 +1,1184 @@ +//! Canonical XML (C14N) serialization. +//! +//! Implements Canonical XML 1.0 per the W3C specification: +//! <https://www.w3.org/TR/xml-c14n/> +//! +//! Canonical XML produces a unique, deterministic byte sequence for logically +//! equivalent XML documents. This is critical for XML digital signatures, where +//! the canonical form must be identical regardless of insignificant variations +//! in the original serialization. +//! +//! # Key C14N rules +//! +//! - No XML declaration in output +//! - Attributes sorted by namespace URI then local name +//! - Namespace declarations sorted by prefix +//! - Empty elements always use start-end tag pairs (`<a></a>`, not `<a/>`) +//! - CDATA sections replaced with escaped text content +//! - Entity references expanded +//! - DOCTYPE declarations removed +//! - Specific character escaping rules for text content and attribute values +//! +//! # Examples +//! +//! ``` +//! use xmloxide::Document; +//! use xmloxide::serial::c14n::{canonicalize, C14nOptions}; +//! +//! let doc = Document::parse_str("<root><child/></root>").unwrap(); +//! let c14n = canonicalize(&doc, &C14nOptions::default()); +//! assert_eq!(c14n, "<root><child></child></root>"); +//! ``` + +use std::collections::BTreeMap; + +use crate::tree::{Document, NodeId, NodeKind}; + +/// Options for canonical XML serialization. +/// +/// Controls the mode of canonicalization: inclusive or exclusive, +/// with or without comments. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::serial::c14n::C14nOptions; +/// +/// // Default: inclusive C14N with comments +/// let opts = C14nOptions::default(); +/// assert!(opts.with_comments); +/// assert!(!opts.exclusive); +/// ``` +#[derive(Debug, Clone)] +pub struct C14nOptions { + /// If true, include comments in output (C14N with comments). + /// If false, strip comments (plain C14N). + pub with_comments: bool, + /// If true, use exclusive C14N (Exclusive XML Canonicalization 1.0). + /// If false, use inclusive C14N. + pub exclusive: bool, + /// For exclusive C14N, the list of additional namespace prefixes to + /// treat as visibly utilized (the `InclusiveNamespaces PrefixList`). + pub inclusive_prefixes: Vec<String>, +} + +impl Default for C14nOptions { + fn default() -> Self { + Self { + with_comments: true, + exclusive: false, + inclusive_prefixes: Vec::new(), + } + } +} + +/// Serializes a document to Canonical XML (C14N 1.0). +/// +/// Processes the entire document according to the Canonical XML specification. +/// The output never includes an XML declaration, DOCTYPE declarations are +/// removed, and all other canonicalization rules are applied. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::Document; +/// use xmloxide::serial::c14n::{canonicalize, C14nOptions}; +/// +/// let doc = Document::parse_str("<root attr2=\"b\" attr1=\"a\"/>").unwrap(); +/// let c14n = canonicalize(&doc, &C14nOptions::default()); +/// // Attributes are sorted, empty element uses start-end tags +/// assert_eq!(c14n, "<root attr1=\"a\" attr2=\"b\"></root>"); +/// ``` +#[must_use] +pub fn canonicalize(doc: &Document, options: &C14nOptions) -> String { + let mut ctx = C14nContext::new(doc, options); + ctx.process_document(); + ctx.output +} + +/// Serializes a subtree (specific node and its descendants) to Canonical XML. +/// +/// This is useful when canonicalizing a portion of a document, for example +/// when computing a digest for a specific element in an XML signature. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::Document; +/// use xmloxide::serial::c14n::{canonicalize_subtree, C14nOptions}; +/// +/// let doc = Document::parse_str("<root><child>text</child></root>").unwrap(); +/// let root = doc.root_element().unwrap(); +/// let c14n = canonicalize_subtree(&doc, root, &C14nOptions::default()); +/// assert_eq!(c14n, "<root><child>text</child></root>"); +/// ``` +#[must_use] +pub fn canonicalize_subtree(doc: &Document, node: NodeId, options: &C14nOptions) -> String { + let mut ctx = C14nContext::new(doc, options); + ctx.process_node(node); + ctx.output +} + +/// A namespace binding: prefix (empty string for default namespace) to URI. +type NsBinding = BTreeMap<String, String>; + +/// Internal context for C14N serialization. +struct C14nContext<'a> { + doc: &'a Document, + options: &'a C14nOptions, + output: String, + /// Stack of namespace bindings currently in scope. + /// Each entry maps prefix -> URI. The stack tracks what has been + /// rendered so far, to avoid redundant re-declarations. + rendered_ns_stack: Vec<NsBinding>, +} + +impl<'a> C14nContext<'a> { + fn new(doc: &'a Document, options: &'a C14nOptions) -> Self { + // Seed the rendered namespace stack with the implicit `xml` prefix + // binding. The XML Namespaces spec reserves `xml` as always bound to + // `http://www.w3.org/XML/1998/namespace`. Canonical XML 1.0 §2.3 + // ("Processing Model") requires that this binding is never emitted: + // "omit namespace node with local name xml, which defines the + // xml prefix, if its string value is + // http://www.w3.org/XML/1998/namespace" + // Exclusive C14N §3 defines itself as a variant of Canonical XML and + // does not restate this rule — it inherits it. Pre-populating the + // binding here makes the dedup check in `compute_ns_declarations` + // filter it out automatically when an element uses `xml:lang`, + // `xml:space`, or `xml:base`. + let mut initial_bindings = NsBinding::new(); + initial_bindings.insert( + "xml".to_string(), + "http://www.w3.org/XML/1998/namespace".to_string(), + ); + Self { + doc, + options, + output: String::new(), + rendered_ns_stack: vec![initial_bindings], + } + } + + /// Processes the entire document node. + fn process_document(&mut self) { + let root = self.doc.root(); + let children: Vec<NodeId> = self.doc.children(root).collect(); + let root_elem_index = children + .iter() + .position(|&id| matches!(self.doc.node(id).kind, NodeKind::Element { .. })); + + for (i, &child) in children.iter().enumerate() { + match &self.doc.node(child).kind { + NodeKind::Comment { .. } if !self.options.with_comments => {} + NodeKind::Comment { content } => { + if let Some(root_idx) = root_elem_index { + if i < root_idx { + write_c14n_comment(&mut self.output, content); + self.output.push('\n'); + } else if i > root_idx { + self.output.push('\n'); + write_c14n_comment(&mut self.output, content); + } + } else { + write_c14n_comment(&mut self.output, content); + } + } + NodeKind::ProcessingInstruction { target, data } => { + if let Some(root_idx) = root_elem_index { + if i < root_idx { + write_c14n_pi(&mut self.output, target, data.as_deref()); + self.output.push('\n'); + } else if i > root_idx { + self.output.push('\n'); + write_c14n_pi(&mut self.output, target, data.as_deref()); + } + } else { + write_c14n_pi(&mut self.output, target, data.as_deref()); + } + } + NodeKind::Element { .. } => { + self.process_element(child); + } + // DOCTYPE, Document, Text, CData, EntityRef nodes at the + // document level are not output in C14N. + _ => {} + } + } + } + + /// Processes a single node, dispatching by kind. + fn process_node(&mut self, id: NodeId) { + match &self.doc.node(id).kind { + NodeKind::Element { .. } => { + self.process_element(id); + } + NodeKind::Text { content } => { + write_c14n_text(&mut self.output, content); + } + NodeKind::CData { content } => { + // CDATA sections are replaced with their escaped text content + write_c14n_text(&mut self.output, content); + } + NodeKind::Comment { content } => { + if self.options.with_comments { + write_c14n_comment(&mut self.output, content); + } + } + NodeKind::ProcessingInstruction { target, data } => { + write_c14n_pi(&mut self.output, target, data.as_deref()); + } + NodeKind::EntityRef { name, .. } => { + // Entity references are expanded. We output the entity + // reference's children (the expansion). If there are no + // children (unexpanded), output the reference as text. + let has_children = self.doc.first_child(id).is_some(); + if has_children { + for child in self.doc.children(id) { + self.process_node(child); + } + } else { + // Fallback: output the predefined entity expansion + let expanded = expand_predefined_entity(name); + write_c14n_text(&mut self.output, expanded); + } + } + NodeKind::DocumentType { .. } | NodeKind::Document => { + // DOCTYPE and Document nodes are not output + } + } + } + + /// Processes an element node according to C14N rules. + /// + /// This is the core of the canonicalization algorithm. It: + /// 1. Collects namespace declarations that need to be output + /// 2. Sorts and outputs namespace declarations + /// 3. Sorts and outputs attributes + /// 4. Recursively processes children + /// 5. Always uses start-end tag pairs (never self-closing) + fn process_element(&mut self, id: NodeId) { + let (name, prefix, namespace, attributes) = match &self.doc.node(id).kind { + NodeKind::Element { + name, + prefix, + namespace, + attributes, + } => ( + name.clone(), + prefix.clone(), + namespace.clone(), + attributes.clone(), + ), + _ => return, + }; + + let qname = match &prefix { + Some(pfx) => format!("{pfx}:{name}"), + None => name.clone(), + }; + + let ns_to_output = self.compute_ns_declarations( + id, + &name, + prefix.as_deref(), + namespace.as_deref(), + &attributes, + ); + + self.output.push('<'); + self.output.push_str(&qname); + self.write_ns_declarations(&ns_to_output); + self.write_sorted_attributes(&attributes); + self.output.push('>'); + + for child in self.doc.children(id) { + self.process_node(child); + } + + self.output.push_str("</"); + self.output.push_str(&qname); + self.output.push('>'); + + self.rendered_ns_stack.pop(); + } + + /// Computes which namespace declarations need to be output for an element, + /// pushes a new rendered namespace scope, and returns the sorted list of + /// (prefix, URI) pairs to emit. + fn compute_ns_declarations( + &mut self, + id: NodeId, + name: &str, + prefix: Option<&str>, + namespace: Option<&str>, + attributes: &[crate::tree::Attribute], + ) -> Vec<(String, String)> { + let ns_decls = if self.options.exclusive { + collect_exclusive_ns_decls( + &self.options.inclusive_prefixes, + prefix, + namespace, + attributes, + ) + } else { + collect_inclusive_ns_decls(attributes) + }; + + let mut current_rendered = self.rendered_ns_stack.last().cloned().unwrap_or_default(); + let mut ns_to_output: Vec<(String, String)> = Vec::new(); + + for (ns_prefix, ns_uri) in &ns_decls { + if current_rendered.get(ns_prefix) == Some(ns_uri) { + continue; + } + + // Special case for `xmlns=""`: per Canonical XML 1.0 §3.7 and + // c14n11 §3.1, the empty default-namespace declaration is only + // emitted to undeclare a *non-empty* inherited default. If no + // non-empty default is currently in scope (parent has no default, + // or the inherited default is itself empty), the `xmlns=""` from + // the source must not appear in the canonical form. + if ns_prefix.is_empty() && ns_uri.is_empty() { + let has_nonempty_inherited_default = + current_rendered.get("").is_some_and(|s| !s.is_empty()); + if !has_nonempty_inherited_default { + continue; + } + } + + ns_to_output.push((ns_prefix.clone(), ns_uri.clone())); + current_rendered.insert(ns_prefix.clone(), ns_uri.clone()); + } + + // The default-namespace undeclaration rule applies in both modes. If + // the parent's default namespace is non-empty and visibly rendered in + // scope, and the current element is in no namespace (the source has + // an explicit `xmlns=""`), the canonical output must emit `xmlns=""` + // to undeclare it. Canonical XML 1.0 §2.3 covers the inclusive case; + // Exclusive C14N §3 inherits the rule (the default prefix is part of + // the visibly-utilized set when an explicit undeclaration is present + // in the source and the inherited default would otherwise propagate). + self.check_default_ns_undeclaration( + &ns_decls, + attributes, + &mut ns_to_output, + &mut current_rendered, + ); + + // Suppress unused variable warnings for future use + let _ = (id, name); + + ns_to_output.sort_by(|a, b| a.0.cmp(&b.0)); + self.rendered_ns_stack.push(current_rendered); + ns_to_output + } + + /// Checks whether a default namespace undeclaration (`xmlns=""`) is needed + /// and adds it to the output list if so. + fn check_default_ns_undeclaration( + &self, + ns_decls: &[(String, String)], + attributes: &[crate::tree::Attribute], + ns_to_output: &mut Vec<(String, String)>, + current_rendered: &mut NsBinding, + ) { + let parent_default = self + .rendered_ns_stack + .last() + .and_then(|m| m.get("")) + .cloned(); + + let has_current_default = ns_decls.iter().any(|(p, _)| p.is_empty()); + + if !has_current_default && parent_default.is_some() && parent_default.as_deref() != Some("") + { + let has_explicit_undecl = attributes + .iter() + .any(|a| a.prefix.is_none() && a.name == "xmlns" && a.value.is_empty()); + if has_explicit_undecl { + ns_to_output.push((String::new(), String::new())); + current_rendered.insert(String::new(), String::new()); + } + } + } + + /// Writes sorted namespace declarations to the output. + fn write_ns_declarations(&mut self, ns_to_output: &[(String, String)]) { + for (ns_prefix, ns_uri) in ns_to_output { + if ns_prefix.is_empty() { + self.output.push_str(" xmlns=\""); + } else { + self.output.push_str(" xmlns:"); + self.output.push_str(ns_prefix); + self.output.push_str("=\""); + } + write_c14n_attr_value(&mut self.output, ns_uri); + self.output.push('"'); + } + } + + /// Writes sorted non-namespace attributes to the output. + fn write_sorted_attributes(&mut self, attributes: &[crate::tree::Attribute]) { + let mut regular_attrs: Vec<_> = attributes + .iter() + .filter(|a| !is_ns_declaration(a)) + .collect(); + + regular_attrs.sort_by(|a, b| { + let a_ns = a.namespace.as_deref().unwrap_or(""); + let b_ns = b.namespace.as_deref().unwrap_or(""); + match a_ns.cmp(b_ns) { + std::cmp::Ordering::Equal => a.name.cmp(&b.name), + other => other, + } + }); + + for attr in &regular_attrs { + self.output.push(' '); + if let Some(pfx) = &attr.prefix { + self.output.push_str(pfx); + self.output.push(':'); + } + self.output.push_str(&attr.name); + self.output.push_str("=\""); + write_c14n_attr_value(&mut self.output, &attr.value); + self.output.push('"'); + } + } +} + +/// Returns true if the attribute is a namespace declaration (`xmlns` or `xmlns:*`). +fn is_ns_declaration(attr: &crate::tree::Attribute) -> bool { + attr.prefix.as_deref() == Some("xmlns") || (attr.prefix.is_none() && attr.name == "xmlns") +} + +/// Collects namespace declarations for inclusive C14N. +/// +/// In inclusive mode, all namespace declarations present on the element's +/// attributes are collected. The rendering stack handles deduplication. +fn collect_inclusive_ns_decls(attributes: &[crate::tree::Attribute]) -> Vec<(String, String)> { + let mut decls = Vec::new(); + for attr in attributes { + if attr.prefix.as_deref() == Some("xmlns") { + decls.push((attr.name.clone(), attr.value.clone())); + } else if attr.prefix.is_none() && attr.name == "xmlns" { + decls.push((String::new(), attr.value.clone())); + } + } + decls +} + +/// Collects namespace declarations for exclusive C14N. +/// +/// In exclusive mode, only "visibly utilized" namespace prefixes are output. +/// A namespace prefix is visibly utilized if it appears as the element's own +/// prefix, an attribute's prefix, or is listed in the `inclusive_prefixes` +/// option. +fn collect_exclusive_ns_decls( + inclusive_prefixes: &[String], + elem_prefix: Option<&str>, + elem_ns: Option<&str>, + attributes: &[crate::tree::Attribute], +) -> Vec<(String, String)> { + let mut all_decls: BTreeMap<String, String> = BTreeMap::new(); + for attr in attributes { + if attr.prefix.as_deref() == Some("xmlns") { + all_decls.insert(attr.name.clone(), attr.value.clone()); + } else if attr.prefix.is_none() && attr.name == "xmlns" { + all_decls.insert(String::new(), attr.value.clone()); + } + } + + let mut utilized: Vec<String> = Vec::new(); + + if let Some(pfx) = elem_prefix { + utilized.push(pfx.to_string()); + } else if elem_ns.is_some() { + utilized.push(String::new()); + } + + for attr in attributes { + if is_ns_declaration(attr) { + continue; + } + if let Some(pfx) = &attr.prefix { + if !utilized.contains(pfx) { + utilized.push(pfx.clone()); + } + } + } + + for pfx in inclusive_prefixes { + let key = if pfx == "#default" { + String::new() + } else { + pfx.clone() + }; + if !utilized.contains(&key) { + utilized.push(key); + } + } + + // Build a map of prefix -> URI from both explicit xmlns declarations + // and the resolved namespace information on the element and attributes. + // This handles the case where a subtree is canonicalized and the xmlns + // declaration lives on an ancestor element. + let mut available_bindings = all_decls; + + // Add the element's own namespace binding + if let (Some(pfx), Some(uri)) = (elem_prefix, elem_ns) { + available_bindings + .entry(pfx.to_string()) + .or_insert_with(|| uri.to_string()); + } else if let (None, Some(uri)) = (elem_prefix, elem_ns) { + available_bindings + .entry(String::new()) + .or_insert_with(|| uri.to_string()); + } + + // Add attribute namespace bindings + for attr in attributes { + if is_ns_declaration(attr) { + continue; + } + if let (Some(pfx), Some(uri)) = (&attr.prefix, &attr.namespace) { + available_bindings + .entry(pfx.clone()) + .or_insert_with(|| uri.clone()); + } + } + + let mut result = Vec::new(); + for pfx in &utilized { + if let Some(uri) = available_bindings.get(pfx) { + result.push((pfx.clone(), uri.clone())); + } + } + result +} + +/// Writes a processing instruction in C14N form. +fn write_c14n_pi(out: &mut String, target: &str, data: Option<&str>) { + out.push_str("<?"); + out.push_str(target); + if let Some(d) = data { + out.push(' '); + out.push_str(d); + } + out.push_str("?>"); +} + +/// Writes a comment in C14N form. +fn write_c14n_comment(out: &mut String, content: &str) { + out.push_str("<!--"); + out.push_str(content); + out.push_str("-->"); +} + +/// Escapes text content per C14N rules. +/// +/// C14N text escaping: `&` -> `&amp;`, `<` -> `&lt;`, `>` -> `&gt;`, +/// `\r` -> `&#xD;` +fn write_c14n_text(out: &mut String, text: &str) { + for ch in text.chars() { + match ch { + '&' => out.push_str("&amp;"), + '<' => out.push_str("&lt;"), + '>' => out.push_str("&gt;"), + '\r' => out.push_str("&#xD;"), + _ => out.push(ch), + } + } +} + +/// Escapes an attribute value per C14N rules. +/// +/// C14N attribute value escaping: `&` -> `&amp;`, `<` -> `&lt;`, +/// `"` -> `&quot;`, `\t` -> `&#x9;`, `\n` -> `&#xA;`, `\r` -> `&#xD;` +fn write_c14n_attr_value(out: &mut String, text: &str) { + for ch in text.chars() { + match ch { + '&' => out.push_str("&amp;"), + '<' => out.push_str("&lt;"), + '"' => out.push_str("&quot;"), + '\t' => out.push_str("&#x9;"), + '\n' => out.push_str("&#xA;"), + '\r' => out.push_str("&#xD;"), + _ => out.push(ch), + } + } +} + +/// Expands a predefined XML entity name to its character value. +fn expand_predefined_entity(name: &str) -> &str { + match name { + "amp" => "&", + "lt" => "<", + "gt" => ">", + "apos" => "'", + "quot" => "\"", + _ => "", + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::tree::Attribute; + + /// Helper: create a document from XML and return its C14N output. + fn c14n(xml: &str) -> String { + let doc = Document::parse_str(xml).unwrap(); + canonicalize(&doc, &C14nOptions::default()) + } + + /// Helper: create a C14N without comments. + fn c14n_no_comments(xml: &str) -> String { + let doc = Document::parse_str(xml).unwrap(); + canonicalize( + &doc, + &C14nOptions { + with_comments: false, + ..C14nOptions::default() + }, + ) + } + + #[test] + fn test_c14n_empty_element_uses_start_end_tags() { + // C14N rule: empty elements always use start-end tag pairs, never + // self-closing. + let result = c14n("<root/>"); + assert_eq!(result, "<root></root>"); + + let result = c14n("<root><child/></root>"); + assert_eq!(result, "<root><child></child></root>"); + } + + #[test] + fn test_c14n_attribute_sorting() { + // C14N rule: attributes sorted by namespace URI then local name. + // Non-namespaced attributes (empty NS URI) come first. + let result = c14n("<root z=\"1\" a=\"2\" m=\"3\"/>"); + assert_eq!(result, "<root a=\"2\" m=\"3\" z=\"1\"></root>"); + } + + #[test] + fn test_c14n_namespace_declaration_ordering() { + // C14N rule: namespace declarations sorted by prefix. + let result = c14n("<root xmlns:z=\"http://z.example\" xmlns:a=\"http://a.example\"/>"); + assert_eq!( + result, + "<root xmlns:a=\"http://a.example\" xmlns:z=\"http://z.example\"></root>" + ); + } + + #[test] + fn test_c14n_text_content_escaping() { + // C14N text escaping: & < > and \r + let mut doc = Document::new(); + let root = doc.root(); + let elem = doc.create_node(NodeKind::Element { + name: "root".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + let text = doc.create_node(NodeKind::Text { + content: "a & b < c > d\re".to_string(), + }); + doc.append_child(root, elem); + doc.append_child(elem, text); + let result = canonicalize(&doc, &C14nOptions::default()); + assert_eq!(result, "<root>a &amp; b &lt; c &gt; d&#xD;e</root>"); + } + + #[test] + fn test_c14n_attribute_value_escaping() { + // C14N attribute value escaping: & < " \t \n \r + let mut doc = Document::new(); + let root = doc.root(); + let elem = doc.create_node(NodeKind::Element { + name: "root".to_string(), + prefix: None, + namespace: None, + attributes: vec![Attribute { + name: "val".to_string(), + value: "a&b<c\"d\te\nf\rg".to_string(), + prefix: None, + namespace: None, + raw_value: None, + }], + }); + doc.append_child(root, elem); + let result = canonicalize(&doc, &C14nOptions::default()); + assert_eq!( + result, + "<root val=\"a&amp;b&lt;c&quot;d&#x9;e&#xA;f&#xD;g\"></root>" + ); + } + + #[test] + fn test_c14n_no_xml_declaration() { + // C14N rule: output never includes XML declaration. + let result = c14n("<?xml version=\"1.0\" encoding=\"UTF-8\"?><root/>"); + assert_eq!(result, "<root></root>"); + assert!(!result.contains("<?xml")); + } + + #[test] + fn test_c14n_cdata_replaced_with_escaped_text() { + // C14N rule: CDATA sections replaced with escaped text content. + let mut doc = Document::new(); + let root = doc.root(); + let elem = doc.create_node(NodeKind::Element { + name: "root".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + let cdata = doc.create_node(NodeKind::CData { + content: "x < 1 && y > 2".to_string(), + }); + doc.append_child(root, elem); + doc.append_child(elem, cdata); + let result = canonicalize(&doc, &C14nOptions::default()); + assert_eq!(result, "<root>x &lt; 1 &amp;&amp; y &gt; 2</root>"); + } + + #[test] + fn test_c14n_comments_included_by_default() { + // C14N with comments: comments are included. + let result = c14n("<root><!-- hello --></root>"); + assert_eq!(result, "<root><!-- hello --></root>"); + } + + #[test] + fn test_c14n_comments_excluded_when_option_set() { + // C14N without comments: comments are stripped. + let result = c14n_no_comments("<root><!-- hello --></root>"); + assert_eq!(result, "<root></root>"); + } + + #[test] + fn test_c14n_doctype_removed() { + // C14N rule: DOCTYPE declarations removed from output. + let mut doc = Document::new(); + let root = doc.root(); + let doctype = doc.create_node(NodeKind::DocumentType { + name: "html".to_string(), + system_id: None, + public_id: None, + internal_subset: None, + }); + let elem = doc.create_node(NodeKind::Element { + name: "html".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + doc.append_child(root, doctype); + doc.append_child(root, elem); + let result = canonicalize(&doc, &C14nOptions::default()); + assert_eq!(result, "<html></html>"); + assert!(!result.contains("DOCTYPE")); + } + + #[test] + fn test_c14n_processing_instructions_preserved() { + // C14N rule: processing instructions are preserved. + let result = c14n("<root><?target data?></root>"); + assert_eq!(result, "<root><?target data?></root>"); + } + + #[test] + fn test_c14n_simple_document_roundtrip() { + // A simple well-formed document should canonicalize predictably. + let result = c14n("<root><a>hello</a><b>world</b></root>"); + assert_eq!(result, "<root><a>hello</a><b>world</b></root>"); + } + + #[test] + fn test_c14n_namespace_handling_default() { + // Default namespace declaration should be output. + let result = c14n("<root xmlns=\"http://example.com\"/>"); + assert_eq!(result, "<root xmlns=\"http://example.com\"></root>"); + } + + #[test] + fn test_c14n_namespace_handling_prefixed() { + // Prefixed namespace declaration should be output. + let result = c14n("<ns:root xmlns:ns=\"http://example.com\"/>"); + assert_eq!( + result, + "<ns:root xmlns:ns=\"http://example.com\"></ns:root>" + ); + } + + #[test] + fn test_c14n_whitespace_only_text_preserved() { + // Whitespace-only text nodes within elements are preserved. + let result = c14n("<root> </root>"); + assert_eq!(result, "<root> </root>"); + + let result = c14n("<root> \n </root>"); + assert_eq!(result, "<root> \n </root>"); + } + + #[test] + fn test_c14n_exclusive_namespace_scoping() { + // In exclusive C14N, only visibly utilized namespaces are output. + let doc = Document::parse_str( + "<root xmlns:a=\"http://a.example\" xmlns:b=\"http://b.example\">\ + <a:child/></root>", + ) + .unwrap(); + + let root_elem = doc.root_element().unwrap(); + let child = doc.first_child(root_elem).unwrap(); + + let result = canonicalize_subtree( + &doc, + child, + &C14nOptions { + with_comments: true, + exclusive: true, + inclusive_prefixes: vec![], + }, + ); + + // In exclusive mode on the subtree, only the "a" namespace that is + // visibly utilized should appear. The "b" namespace should not. + assert!(result.contains("xmlns:a=")); + assert!(!result.contains("xmlns:b=")); + } + + #[test] + fn test_c14n_complex_document_all_node_types() { + // A document exercising many node types together. + let mut doc = Document::new(); + let root = doc.root(); + + // Comment before root element + let comment_before = doc.create_node(NodeKind::Comment { + content: " prologue comment ".to_string(), + }); + doc.append_child(root, comment_before); + + // PI before root element + let pi_before = doc.create_node(NodeKind::ProcessingInstruction { + target: "app".to_string(), + data: Some("start".to_string()), + }); + doc.append_child(root, pi_before); + + // Root element with attributes + let elem = doc.create_node(NodeKind::Element { + name: "root".to_string(), + prefix: None, + namespace: None, + attributes: vec![ + Attribute { + name: "z".to_string(), + value: "1".to_string(), + prefix: None, + namespace: None, + raw_value: None, + }, + Attribute { + name: "a".to_string(), + value: "2".to_string(), + prefix: None, + namespace: None, + raw_value: None, + }, + ], + }); + doc.append_child(root, elem); + + // Text child + let text = doc.create_node(NodeKind::Text { + content: "hello".to_string(), + }); + doc.append_child(elem, text); + + // CDATA child + let cdata = doc.create_node(NodeKind::CData { + content: "a<b".to_string(), + }); + doc.append_child(elem, cdata); + + // Comment child + let inner_comment = doc.create_node(NodeKind::Comment { + content: " inner ".to_string(), + }); + doc.append_child(elem, inner_comment); + + // PI child + let inner_pi = doc.create_node(NodeKind::ProcessingInstruction { + target: "proc".to_string(), + data: None, + }); + doc.append_child(elem, inner_pi); + + // Comment after root element + let comment_after = doc.create_node(NodeKind::Comment { + content: " epilogue ".to_string(), + }); + doc.append_child(root, comment_after); + + let result = canonicalize(&doc, &C14nOptions::default()); + + // Expected: comment + newline, PI + newline, root element + // (sorted attrs), content, newline + trailing comment + assert_eq!( + result, + "<!-- prologue comment -->\n\ + <?app start?>\n\ + <root a=\"2\" z=\"1\">helloa&lt;b<!-- inner --><?proc?></root>\n\ + <!-- epilogue -->" + ); + } + + #[test] + fn test_c14n_subtree_serialization() { + // Canonicalize only a subtree of a larger document. + let doc = Document::parse_str("<root><child attr=\"value\">text</child></root>").unwrap(); + let root_elem = doc.root_element().unwrap(); + let child = doc.first_child(root_elem).unwrap(); + + let result = canonicalize_subtree(&doc, child, &C14nOptions::default()); + assert_eq!(result, "<child attr=\"value\">text</child>"); + } + + #[test] + fn test_c14n_redundant_namespace_not_redeclared() { + // When a child element inherits a namespace from a parent, + // C14N should not re-declare it. + let result = c14n( + "<root xmlns=\"http://example.com\">\ + <child xmlns=\"http://example.com\"/></root>", + ); + // The child should not have xmlns re-declared + assert_eq!( + result, + "<root xmlns=\"http://example.com\"><child></child></root>" + ); + } + + #[test] + fn test_c14n_mixed_namespace_and_regular_attrs() { + // Namespace declarations come before regular attributes, + // sorted by prefix. Regular attributes sorted by ns URI then + // local name. + let result = + c14n("<root xmlns:b=\"http://b\" xmlns:a=\"http://a\" b:y=\"1\" a:x=\"2\" c=\"3\"/>"); + // Namespace decls: xmlns:a, xmlns:b (sorted by prefix) + // Regular attrs: c (no ns, empty URI), a:x (http://a), b:y (http://b) + assert_eq!( + result, + "<root xmlns:a=\"http://a\" xmlns:b=\"http://b\" c=\"3\" a:x=\"2\" b:y=\"1\"></root>" + ); + } + + #[test] + fn test_c14n_pi_without_data() { + // Processing instruction with no data. + let result = c14n("<root><?target?></root>"); + assert_eq!(result, "<root><?target?></root>"); + } + + #[test] + fn test_c14n_nested_elements() { + // Deeply nested elements all use start-end tags. + let result = c14n("<a><b><c/></b></a>"); + assert_eq!(result, "<a><b><c></c></b></a>"); + } + + #[test] + fn test_c14n_exclusive_with_inclusive_prefixes() { + // Exclusive C14N with additional inclusive prefixes. + let doc = Document::parse_str( + "<root xmlns:a=\"http://a\" xmlns:b=\"http://b\">\ + <child/></root>", + ) + .unwrap(); + let root_elem = doc.root_element().unwrap(); + let child = doc.first_child(root_elem).unwrap(); + + let result = canonicalize_subtree( + &doc, + child, + &C14nOptions { + with_comments: true, + exclusive: true, + inclusive_prefixes: vec!["b".to_string()], + }, + ); + + // The "b" prefix is forced via inclusive_prefixes even though + // it's not visibly utilized on the child element. + // However, the child element doesn't have the xmlns:b declaration + // as an attribute, so it won't appear (it's on the parent). + // This tests the boundary condition. + assert_eq!(result, "<child></child>"); + } + + #[test] + fn test_c14n_document_comments_and_pis_spacing() { + // Comments and PIs before the root element get a trailing newline. + // Comments and PIs after the root element get a leading newline. + let mut doc = Document::new(); + let root = doc.root(); + + let pi = doc.create_node(NodeKind::ProcessingInstruction { + target: "before".to_string(), + data: None, + }); + doc.append_child(root, pi); + + let elem = doc.create_node(NodeKind::Element { + name: "root".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + doc.append_child(root, elem); + + let pi_after = doc.create_node(NodeKind::ProcessingInstruction { + target: "after".to_string(), + data: None, + }); + doc.append_child(root, pi_after); + + let result = canonicalize(&doc, &C14nOptions::default()); + assert_eq!(result, "<?before?>\n<root></root>\n<?after?>"); + } + + #[test] + fn test_c14n_inclusive_xml_prefix_not_emitted() { + // XML Namespaces reserves the `xml` prefix as implicitly bound to + // `http://www.w3.org/XML/1998/namespace`. Canonical XML §2.3 requires + // that this binding is never emitted as an `xmlns:xml` declaration. + let result = c14n("<root xml:lang=\"en\">hello</root>"); + assert_eq!(result, "<root xml:lang=\"en\">hello</root>"); + assert!( + !result.contains("xmlns:xml"), + "implicit xml namespace should not be emitted, got: {result}" + ); + } + + #[test] + fn test_c14n_exclusive_xml_prefix_not_emitted_on_root() { + // Exclusive C14N §3 is defined as a variant of Canonical XML and + // inherits the §2.3 rule that suppresses the implicit `xml` prefix + // binding from canonical output. + let doc = Document::parse_str("<root xml:lang=\"en\">hello</root>").unwrap(); + let result = canonicalize( + &doc, + &C14nOptions { + with_comments: false, + exclusive: true, + inclusive_prefixes: vec![], + }, + ); + assert_eq!(result, "<root xml:lang=\"en\">hello</root>"); + assert!(!result.contains("xmlns:xml")); + } + + #[test] + fn test_c14n_exclusive_xml_prefix_not_emitted_on_subtree() { + // When canonicalizing a subtree whose ancestor carries `xml:lang`, + // the subtree must not spuriously declare `xmlns:xml` just because + // an `xml:` attribute appears in scope. + let doc = Document::parse_str( + "<root xmlns=\"http://example.com\" xml:lang=\"en\">\ + <child xml:space=\"preserve\">hi</child></root>", + ) + .unwrap(); + let root = doc.root_element().unwrap(); + let child = doc + .children(root) + .find(|&n| doc.node_name(n) == Some("child")) + .unwrap(); + + let result = canonicalize_subtree( + &doc, + child, + &C14nOptions { + with_comments: false, + exclusive: true, + inclusive_prefixes: vec![], + }, + ); + + assert!( + !result.contains("xmlns:xml"), + "implicit xml namespace should not be emitted in exclusive C14N, got: {result}" + ); + // The default namespace from the root IS visibly utilized by the + // child element's unprefixed name, so it should appear. + assert!(result.contains("xmlns=\"http://example.com\"")); + assert!(result.contains("xml:space=\"preserve\"")); + } + + /// W3C Canonical XML §2.3 — when a child element is in no namespace under + /// a parent that has a non-empty default namespace, the canonical output + /// must emit `xmlns=""` to undeclare the inherited default. This is the + /// inclusive-mode baseline; libxml2's `xmllint --c14n` exhibits the same. + #[test] + fn test_c14n_inclusive_emits_default_ns_undeclaration() { + let xml = + r#"<Envelope xmlns="http://example.org/usps"><NonNs xmlns="">child</NonNs></Envelope>"#; + let result = c14n(xml); + assert!( + result.contains("<NonNs xmlns=\"\">"), + "default namespace undeclaration missing, got: {result}" + ); + } + + /// W3C Exclusive C14N §3 inherits Canonical XML's default-namespace + /// undeclaration rule. Without it, the canonical form leaks the parent's + /// default namespace into a child that explicitly has none, producing a + /// digest that diverges from libxml2 / xmlsec. + #[test] + fn test_c14n_exclusive_emits_default_ns_undeclaration() { + let xml = + r#"<Envelope xmlns="http://example.org/usps"><NonNs xmlns="">child</NonNs></Envelope>"#; + let doc = Document::parse_str(xml).unwrap(); + let result = canonicalize( + &doc, + &C14nOptions { + with_comments: false, + exclusive: true, + inclusive_prefixes: vec![], + }, + ); + assert!( + result.contains("<NonNs xmlns=\"\">"), + "exclusive C14N must emit xmlns=\"\" to undeclare inherited default ns, got: {result}" + ); + } + + /// Negative companion: when no inherited default exists, the output must + /// NOT emit a spurious `xmlns=""`. + #[test] + fn test_c14n_exclusive_no_undeclaration_when_no_inherited_default() { + let xml = r"<root><child>x</child></root>"; + let doc = Document::parse_str(xml).unwrap(); + let result = canonicalize( + &doc, + &C14nOptions { + with_comments: false, + exclusive: true, + inclusive_prefixes: vec![], + }, + ); + assert!( + !result.contains("xmlns=\"\""), + "exclusive C14N must not emit xmlns=\"\" when no inherited default to undeclare, got: {result}" + ); + } +} diff --git a/browser/vendor/xmloxide/src/serial/html.rs b/browser/vendor/xmloxide/src/serial/html.rs new file mode 100644 index 000000000..a5390541b --- /dev/null +++ b/browser/vendor/xmloxide/src/serial/html.rs @@ -0,0 +1,1209 @@ +//! HTML serializer. +//! +//! Serializes a `Document` tree into an HTML string, following libxml2's +//! `htmlSaveFile` behavior. Key differences from XML serialization: +//! +//! - No XML declaration (`<?xml ...?>`) +//! - Void elements use `<br>` syntax (no `/>`) +//! - Non-void empty elements use `<p></p>` (no `<p/>`) +//! - Raw text elements (script, style) are not escaped +//! - Non-ASCII characters are re-encoded as HTML named entities where possible +//! - Formatting newlines around block-level elements + +use crate::html::entities::reverse_lookup_entity; +use crate::html::{is_boolean_attribute, is_raw_text_element, is_void_element}; +use crate::tree::{Document, NodeId, NodeKind}; + +/// Serializes a document to an HTML string. +/// +/// Produces output compatible with libxml2's HTML serialization: +/// - DOCTYPE declaration (if present, or default HTML 4.0 Transitional) +/// - HTML void elements serialized without self-closing slash +/// - Script/style content preserved without escaping +/// - Non-ASCII characters re-encoded as named HTML entities +/// +/// # Examples +/// +/// ``` +/// use xmloxide::html::parse_html; +/// use xmloxide::serial::html::serialize_html; +/// +/// let doc = parse_html("<p>Hello</p>").unwrap(); +/// let html = serialize_html(&doc); +/// assert!(html.contains("<p>")); +/// ``` +#[must_use] +pub fn serialize_html(doc: &Document) -> String { + let mut output = String::new(); + + // Detect whether the document declares UTF-8 charset. + // If so, non-ASCII characters are preserved as raw UTF-8. + // Otherwise (default ISO-8859-1), they are re-encoded as named entities. + let reencode = !detect_utf8_charset(doc); + + // Serialize children of the document root (DOCTYPE, elements, etc.) + for child in doc.children(doc.root()) { + serialize_html_node(doc, child, &mut output, reencode); + } + + // Trailing newline (matches libxml2 output convention) + if !output.ends_with('\n') { + output.push('\n'); + } + + output +} + +/// Serializes one node without the document serializer's formatting newlines +/// or legacy non-ASCII re-encoding. This matches `lxml.html.tostring(..., +/// encoding="unicode")`, which Scrapling exposes as `html_content`. +#[must_use] +pub fn serialize_html_subtree(doc: &Document, node: NodeId) -> String { + let mut output = String::new(); + serialize_html_subtree_node(doc, node, &mut output); + output +} + +fn serialize_html_subtree_node(doc: &Document, id: NodeId, out: &mut String) { + match &doc.node(id).kind { + NodeKind::Element { + name, + prefix, + attributes, + .. + } => { + out.push('<'); + if let Some(prefix) = prefix { + out.push_str(prefix); + out.push(':'); + } + out.push_str(name); + for attr in attributes { + out.push(' '); + if let Some(prefix) = &attr.prefix { + out.push_str(prefix); + out.push(':'); + } + out.push_str(&attr.name); + if !is_boolean_attribute(&attr.name) && attr.raw_value.as_deref() != Some("") { + out.push_str("=\""); + write_unicode_attribute(out, &attr.value); + out.push('"'); + } + } + out.push('>'); + if is_void_element(&name.to_ascii_lowercase()) { + return; + } + let lower_name = name.to_ascii_lowercase(); + if is_raw_text_element(&lower_name) { + for child in doc.children(id) { + if let Some(text) = doc.node_text(child) { + out.push_str(text); + } else { + serialize_html_subtree_node(doc, child, out); + } + } + } else if lower_name == "plaintext" { + for child in doc.children(id) { + if let Some(text) = doc.node_text(child) { + write_plaintext(out, text); + } else { + serialize_html_subtree_node(doc, child, out); + } + } + } else { + for child in doc.children(id) { + serialize_html_subtree_node(doc, child, out); + } + } + out.push_str("</"); + if let Some(prefix) = prefix { + out.push_str(prefix); + out.push(':'); + } + out.push_str(name); + out.push('>'); + } + NodeKind::Text { content } | NodeKind::CData { content } => { + write_unicode_text(out, content); + } + NodeKind::Comment { content } => { + out.push_str("<!--"); + out.push_str(content); + out.push_str("-->"); + } + NodeKind::EntityRef { value, name } => { + if let Some(value) = value { + write_unicode_text(out, value); + } else { + out.push('&'); + out.push_str(name); + out.push(';'); + } + } + NodeKind::ProcessingInstruction { target, data } => { + out.push_str("<?"); + out.push_str(target); + if let Some(data) = data { + out.push(' '); + out.push_str(data); + } + out.push('>'); + } + NodeKind::Document | NodeKind::DocumentType { .. } => { + for child in doc.children(id) { + serialize_html_subtree_node(doc, child, out); + } + } + } +} + +fn write_unicode_text(out: &mut String, value: &str) { + for ch in value.chars() { + match ch { + '&' => out.push_str("&amp;"), + '<' => out.push_str("&lt;"), + '>' => out.push_str("&gt;"), + _ => out.push(ch), + } + } +} + +fn write_plaintext(out: &mut String, value: &str) { + for ch in value.chars() { + match ch { + '&' => out.push_str("&amp;"), + '<' => out.push_str("&lt;"), + '>' => out.push_str("&gt;"), + _ => out.push(ch), + } + } +} + +fn write_unicode_attribute(out: &mut String, value: &str) { + for ch in value.chars() { + match ch { + '&' => out.push_str("&amp;"), + '"' => out.push_str("&quot;"), + '<' => out.push_str("&lt;"), + '>' => out.push_str("&gt;"), + _ => out.push(ch), + } + } +} + +/// Serializes a document produced by the HTML5 parser to an HTML string. +/// +/// Unlike [`serialize_html`] (which targets libxml2's HTML 4.01 output), +/// this function always preserves non-ASCII characters as raw UTF-8 and +/// uses self-closing syntax for foreign content elements (SVG, `MathML`). +/// +/// # Examples +/// +/// ``` +/// use xmloxide::html5::parse_html5; +/// use xmloxide::serial::html::serialize_html5; +/// +/// let doc = parse_html5("<p>Hello</p>").unwrap(); +/// let html = serialize_html5(&doc); +/// assert!(html.contains("<p>Hello</p>")); +/// ``` +#[must_use] +pub fn serialize_html5(doc: &Document) -> String { + let mut output = String::new(); + + for child in doc.children(doc.root()) { + serialize_html5_node(doc, child, &mut output); + } + + if !output.ends_with('\n') { + output.push('\n'); + } + + output +} + +/// Detects whether the document declares a UTF-8 charset via `<meta>` tags. +/// +/// Checks for: +/// - `<meta charset="utf-8">` +/// - `<meta http-equiv="Content-Type" content="...charset=utf-8...">` +/// +/// When the charset is UTF-8, non-ASCII characters are preserved as raw +/// UTF-8 in the output. Otherwise (default ISO-8859-1 for HTML), they +/// are re-encoded as named HTML entities. +fn detect_utf8_charset(doc: &Document) -> bool { + let root = doc.root(); + for id in doc.children(root) { + if check_meta_charset(doc, id) { + return true; + } + } + false +} + +/// Recursively checks an element subtree for meta charset declarations. +fn check_meta_charset(doc: &Document, id: NodeId) -> bool { + if let NodeKind::Element { + name, attributes, .. + } = &doc.node(id).kind + { + if name == "meta" { + // Check <meta charset="utf-8"> + for attr in attributes { + if attr.name == "charset" && attr.value.eq_ignore_ascii_case("utf-8") { + return true; + } + } + // Check <meta http-equiv="Content-Type" content="...charset=utf-8..."> + let is_content_type = attributes + .iter() + .any(|a| a.name == "http-equiv" && a.value.eq_ignore_ascii_case("content-type")); + if is_content_type { + for attr in attributes { + if attr.name == "content" { + let lower = attr.value.to_ascii_lowercase(); + if lower.contains("charset=utf-8") { + return true; + } + } + } + } + } + // Recurse into children + for child in doc.children(id) { + if check_meta_charset(doc, child) { + return true; + } + } + } + false +} + +/// Returns true if the element is an HTML inline element. +/// +/// libxml2 categorizes elements as inline or block-level. Block-level +/// elements get formatting newlines around them in the serialized output. +fn is_inline_element(tag: &str) -> bool { + matches!( + tag, + "a" | "abbr" + | "acronym" + | "b" + | "bdo" + | "big" + | "br" + | "cite" + | "code" + | "dfn" + | "em" + | "font" + | "i" + | "img" + | "input" + | "kbd" + | "label" + | "q" + | "s" + | "samp" + | "select" + | "small" + | "span" + | "strike" + | "strong" + | "sub" + | "sup" + | "textarea" + | "tt" + | "u" + | "var" + ) +} + +/// Returns true if the node kind is text-like (`Text`, `CData`, or `EntityRef`). +/// +/// libxml2 suppresses formatting newlines when adjacent to text-like nodes. +fn is_text_like(kind: &NodeKind) -> bool { + matches!( + kind, + NodeKind::Text { .. } | NodeKind::CData { .. } | NodeKind::EntityRef { .. } + ) +} + +/// Checks whether a formatting newline should be added after a block-level +/// element's opening tag (libxml2 behavior). +/// +/// Adds `\n` when: +/// - Element is not inline +/// - Element name does not start with 'p' (p, pre, param) +/// - First child is not a text-like node +/// - Element has more than one child +fn maybe_newline_after_open(doc: &Document, id: NodeId, tag: &str, out: &mut String) { + if is_inline_element(tag) || tag.starts_with('p') { + return; + } + let Some(first) = doc.first_child(id) else { + return; + }; + if is_text_like(&doc.node(first).kind) { + return; + } + // Check that the element has more than one child + if doc.next_sibling(first).is_none() { + return; + } + out.push('\n'); +} + +/// Checks whether a formatting newline should be added before a block-level +/// element's closing tag (libxml2 behavior). +/// +/// Adds `\n` when: +/// - Element is not inline +/// - Element name does not start with 'p' (p, pre, param) +/// - Last child is not a text-like node +/// - Element has more than one child +fn maybe_newline_before_close(doc: &Document, id: NodeId, tag: &str, out: &mut String) { + if is_inline_element(tag) || tag.starts_with('p') { + return; + } + let Some(first) = doc.first_child(id) else { + return; + }; + let Some(last) = doc.last_child(id) else { + return; + }; + if is_text_like(&doc.node(last).kind) { + return; + } + // More than one child + if doc.next_sibling(first).is_none() { + return; + } + out.push('\n'); +} + +/// Checks whether a formatting newline should be added after a block-level +/// element's closing tag (libxml2 behavior). +/// +/// Adds `\n` when: +/// - Element is not inline +/// - Next sibling exists and is not a text-like node +/// - Parent element name does not start with 'p' +fn maybe_newline_after_close(doc: &Document, id: NodeId, tag: &str, out: &mut String) { + if is_inline_element(tag) { + return; + } + let Some(next) = doc.next_sibling(id) else { + return; + }; + if is_text_like(&doc.node(next).kind) { + return; + } + if let Some(parent) = doc.parent(id) { + let parent_name = doc.node_name(parent).unwrap_or(""); + if parent_name.starts_with('p') { + return; + } + } + out.push('\n'); +} + +#[allow(clippy::too_many_lines)] +fn serialize_html_node(doc: &Document, id: NodeId, out: &mut String, reencode: bool) { + match &doc.node(id).kind { + NodeKind::Element { + name, + prefix, + attributes, + .. + } => { + out.push('<'); + if let Some(pfx) = prefix { + out.push_str(pfx); + out.push(':'); + } + out.push_str(name); + + for attr in attributes { + out.push(' '); + if let Some(pfx) = &attr.prefix { + out.push_str(pfx); + out.push(':'); + } + out.push_str(&attr.name); + if !is_boolean_attribute(&attr.name) && attr.raw_value.as_deref() != Some("") { + // Use single quotes when value contains double quotes + if attr.value.contains('"') && !attr.value.contains('\'') { + out.push_str("='"); + write_html_escaped_attr_sq(out, &attr.value, reencode); + out.push('\''); + } else { + out.push_str("=\""); + if is_uri_attribute(&attr.name) { + write_html_uri_attr(out, &attr.value, reencode); + } else { + write_html_escaped_attr(out, &attr.value, reencode); + } + out.push('"'); + } + } + } + out.push('>'); + + let lower = name.to_ascii_lowercase(); + + // Void elements: no closing tag + if is_void_element(&lower) { + maybe_newline_after_close(doc, id, &lower, out); + return; + } + + // Formatting newline after opening tag for block elements + maybe_newline_after_open(doc, id, &lower, out); + + // Raw text elements: output content without escaping + if is_raw_text_element(&lower) { + for child in doc.children(id) { + if let NodeKind::Text { content } = &doc.node(child).kind { + out.push_str(content); + } else { + serialize_html_node(doc, child, out, reencode); + } + } + } else { + for child in doc.children(id) { + serialize_html_node(doc, child, out, reencode); + } + } + + // Formatting newline before closing tag for block elements + maybe_newline_before_close(doc, id, &lower, out); + + // Closing tag + out.push_str("</"); + if let Some(pfx) = prefix { + out.push_str(pfx); + out.push(':'); + } + out.push_str(name); + out.push('>'); + + // Formatting newline after closing tag for block elements + maybe_newline_after_close(doc, id, &lower, out); + } + NodeKind::Text { content } => { + write_html_escaped_text(out, content, reencode); + } + NodeKind::CData { content } => { + // In HTML, CDATA is not standard — output as text + write_html_escaped_text(out, content, reencode); + } + NodeKind::Comment { content } => { + out.push_str("<!--"); + out.push_str(content); + out.push_str("-->"); + } + NodeKind::ProcessingInstruction { target, data } => { + // HTML PIs use '>' as terminator, not '?>' (XML style) + out.push_str("<?"); + out.push_str(target); + if let Some(d) = data { + out.push(' '); + out.push_str(d); + } + out.push('>'); + } + NodeKind::EntityRef { name, .. } => { + out.push('&'); + out.push_str(name); + out.push(';'); + } + NodeKind::DocumentType { + name, + system_id, + public_id, + .. + } => { + out.push_str("<!DOCTYPE "); + out.push_str(name); + match (public_id, system_id) { + (Some(pub_id), Some(sys_id)) => { + out.push_str(" PUBLIC \""); + out.push_str(pub_id); + out.push('"'); + if !sys_id.is_empty() { + out.push_str(" \""); + out.push_str(sys_id); + out.push('"'); + } + } + (Some(pub_id), None) => { + out.push_str(" PUBLIC \""); + out.push_str(pub_id); + out.push('"'); + } + (None, Some(sys_id)) => { + out.push_str(" SYSTEM \""); + out.push_str(sys_id); + out.push('"'); + } + _ => {} + } + out.push_str(">\n"); + } + NodeKind::Document => { + // Should not appear as a child node + } + } +} + +/// Escapes text content for HTML output. +/// +/// - `&` → `&amp;` +/// - `<` → `&lt;` +/// - `>` → `&gt;` +/// - Non-ASCII characters with known HTML entities → `&name;` (when `reencode` is true) +fn write_html_escaped_text(out: &mut String, text: &str, reencode: bool) { + for ch in text.chars() { + match ch { + '&' => out.push_str("&amp;"), + '<' => out.push_str("&lt;"), + '>' => out.push_str("&gt;"), + c if reencode && (c as u32) >= 0x80 => { + if let Some(name) = reverse_lookup_entity(c) { + out.push('&'); + out.push_str(name); + out.push(';'); + } else { + out.push(c); + } + } + _ => out.push(ch), + } + } +} + +/// Returns true if the attribute name is a URI-type attribute that should +/// have its value URL-encoded (spaces → `%20`, etc.). +fn is_uri_attribute(name: &str) -> bool { + matches!( + name, + "href" + | "src" + | "action" + | "background" + | "cite" + | "classid" + | "codebase" + | "data" + | "longdesc" + | "profile" + | "usemap" + ) +} + +/// Writes a URI attribute value with URL encoding for non-URI characters. +/// +/// Spaces are encoded as `%20`. HTML-special characters (`&`, `<`, `>`) +/// are entity-escaped. Non-ASCII characters are handled based on the +/// `reencode` flag. +fn write_html_uri_attr(out: &mut String, text: &str, reencode: bool) { + for ch in text.chars() { + match ch { + '&' => out.push_str("&amp;"), + '"' => out.push_str("&quot;"), + '<' => out.push_str("&lt;"), + '>' => out.push_str("&gt;"), + ' ' => out.push_str("%20"), + c if reencode && (c as u32) >= 0x80 => { + if let Some(name) = reverse_lookup_entity(c) { + out.push('&'); + out.push_str(name); + out.push(';'); + } else { + out.push(c); + } + } + _ => out.push(ch), + } + } +} + +/// Escapes an attribute value for HTML output (single-quote delimited). +/// +/// Used when the value contains `"` characters and is delimited by `'`. +/// - `&` → `&amp;` +/// - `'` → `&#39;` +/// - `<` → `&lt;` +/// - `>` → `&gt;` +/// - Non-ASCII characters with known HTML entities → `&name;` (when `reencode` is true) +fn write_html_escaped_attr_sq(out: &mut String, text: &str, reencode: bool) { + for ch in text.chars() { + match ch { + '&' => out.push_str("&amp;"), + '\'' => out.push_str("&#39;"), + '<' => out.push_str("&lt;"), + '>' => out.push_str("&gt;"), + c if reencode && (c as u32) >= 0x80 => { + if let Some(name) = reverse_lookup_entity(c) { + out.push('&'); + out.push_str(name); + out.push(';'); + } else { + out.push(c); + } + } + _ => out.push(ch), + } + } +} + +/// Escapes an attribute value for HTML output. +/// +/// - `&` → `&amp;` +/// - `"` → `&quot;` +/// - `<` → `&lt;` +/// - `>` → `&gt;` +/// - Non-ASCII characters with known HTML entities → `&name;` (when `reencode` is true) +fn write_html_escaped_attr(out: &mut String, text: &str, reencode: bool) { + for ch in text.chars() { + match ch { + '&' => out.push_str("&amp;"), + '"' => out.push_str("&quot;"), + '<' => out.push_str("&lt;"), + '>' => out.push_str("&gt;"), + c if reencode && (c as u32) >= 0x80 => { + if let Some(name) = reverse_lookup_entity(c) { + out.push('&'); + out.push_str(name); + out.push(';'); + } else { + out.push(c); + } + } + _ => out.push(ch), + } + } +} + +// --------------------------------------------------------------------------- +// HTML5 serialization +// --------------------------------------------------------------------------- + +/// HTML5 void elements (WHATWG §13.1.2). +fn is_html5_void(tag: &str) -> bool { + matches!( + tag, + "area" + | "base" + | "br" + | "col" + | "embed" + | "hr" + | "img" + | "input" + | "link" + | "meta" + | "source" + | "track" + | "wbr" + ) +} + +/// HTML5 raw text elements (content is not escaped). +fn is_html5_raw_text(tag: &str) -> bool { + matches!(tag, "script" | "style") +} + +/// Serialize a single node for HTML5 output. +fn serialize_html5_node(doc: &Document, id: NodeId, out: &mut String) { + match &doc.node(id).kind { + NodeKind::Element { + name, + namespace, + attributes, + .. + } => { + let is_foreign = namespace.as_deref().is_some_and(|ns| { + ns == "http://www.w3.org/2000/svg" || ns == "http://www.w3.org/1998/Math/MathML" + }); + + out.push('<'); + out.push_str(name); + + for attr in attributes { + out.push(' '); + if let Some(pfx) = &attr.prefix { + out.push_str(pfx); + out.push(':'); + } + out.push_str(&attr.name); + out.push_str("=\""); + write_html5_escaped_attr(out, &attr.value); + out.push('"'); + } + + let lower = name.to_ascii_lowercase(); + + // Void elements: no closing tag + if !is_foreign && is_html5_void(&lower) { + out.push('>'); + return; + } + + // Foreign content with no children: self-closing + if is_foreign && doc.first_child(id).is_none() { + out.push_str("/>"); + return; + } + + out.push('>'); + + // Raw text elements: output content without escaping + if is_html5_raw_text(&lower) { + for child in doc.children(id) { + if let NodeKind::Text { content } = &doc.node(child).kind { + out.push_str(content); + } + } + } else { + for child in doc.children(id) { + serialize_html5_node(doc, child, out); + } + } + + out.push_str("</"); + out.push_str(name); + out.push('>'); + } + NodeKind::Text { content } => { + write_html5_escaped_text(out, content); + } + NodeKind::Comment { content } => { + out.push_str("<!--"); + out.push_str(content); + out.push_str("-->"); + } + NodeKind::DocumentType { + name, + public_id, + system_id, + .. + } => { + out.push_str("<!DOCTYPE "); + out.push_str(name); + if let Some(pub_id) = public_id { + out.push_str(" PUBLIC \""); + out.push_str(pub_id); + out.push('"'); + if let Some(sys_id) = system_id { + out.push_str(" \""); + out.push_str(sys_id); + out.push('"'); + } + } else if let Some(sys_id) = system_id { + out.push_str(" SYSTEM \""); + out.push_str(sys_id); + out.push('"'); + } + out.push_str(">\n"); + } + NodeKind::ProcessingInstruction { target, data } => { + out.push_str("<?"); + out.push_str(target); + if let Some(d) = data { + out.push(' '); + out.push_str(d); + } + out.push('>'); + } + _ => { + for child in doc.children(id) { + serialize_html5_node(doc, child, out); + } + } + } +} + +/// Escape text content for HTML5 output (always UTF-8). +fn write_html5_escaped_text(out: &mut String, text: &str) { + for ch in text.chars() { + match ch { + '&' => out.push_str("&amp;"), + '<' => out.push_str("&lt;"), + '>' => out.push_str("&gt;"), + _ => out.push(ch), + } + } +} + +/// Escape an attribute value for HTML5 output. +fn write_html5_escaped_attr(out: &mut String, text: &str) { + for ch in text.chars() { + match ch { + '&' => out.push_str("&amp;"), + '"' => out.push_str("&quot;"), + _ => out.push(ch), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::html::parse_html; + + // -- Void elements ------------------------------------------------------- + + #[test] + fn test_void_element_br() { + let doc = parse_html("<html><body><br></body></html>").unwrap(); + let html = serialize_html(&doc); + assert!(html.contains("<br>"), "expected <br>, got: {html}"); + assert!(!html.contains("<br/>"), "should not have <br/>"); + assert!(!html.contains("</br>"), "should not have </br>"); + } + + #[test] + fn test_void_element_img_with_attr() { + let doc = parse_html(r#"<html><body><img src="x.png"></body></html>"#).unwrap(); + let html = serialize_html(&doc); + assert!( + html.contains(r#"<img src="x.png">"#), + "expected img with src, got: {html}" + ); + assert!(!html.contains("</img>"), "void element should not close"); + } + + // -- Non-void elements --------------------------------------------------- + + #[test] + fn test_non_void_empty_element() { + let doc = parse_html("<html><body><p></p></body></html>").unwrap(); + let html = serialize_html(&doc); + assert!( + html.contains("<p></p>"), + "expected <p></p>, not self-closing, got: {html}" + ); + } + + // -- Raw text elements --------------------------------------------------- + + #[test] + fn test_script_not_escaped() { + let doc = parse_html("<html><body><script>if (a < b) {}</script></body></html>").unwrap(); + let html = serialize_html(&doc); + assert!( + html.contains("if (a < b) {}"), + "script content should not be escaped, got: {html}" + ); + assert!( + !html.contains("&lt;"), + "script content should not contain &lt;" + ); + } + + #[test] + fn test_style_not_escaped() { + let doc = parse_html("<html><body><style>.a > .b {}</style></body></html>").unwrap(); + let html = serialize_html(&doc); + assert!( + html.contains(".a > .b {}"), + "style content should not be escaped, got: {html}" + ); + assert!( + !html.contains("&gt;"), + "style content should not contain &gt; inside style tag" + ); + } + + // -- Attributes ---------------------------------------------------------- + + #[test] + fn test_boolean_attribute() { + let doc = parse_html(r#"<html><body><input disabled="disabled"></body></html>"#).unwrap(); + let html = serialize_html(&doc); + // Boolean attribute: when value == name, output without value + assert!( + html.contains("<input disabled>") || html.contains("<input disabled "), + "expected boolean attr, got: {html}" + ); + } + + #[test] + fn test_regular_attribute_preserved() { + let doc = parse_html(r#"<html><body><input type="text"></body></html>"#).unwrap(); + let html = serialize_html(&doc); + assert!( + html.contains(r#"type="text""#), + "expected type=\"text\", got: {html}" + ); + } + + #[test] + fn test_multiple_attributes() { + let doc = parse_html( + r#"<html><body><input type="text" name="field" value="hello"></body></html>"#, + ) + .unwrap(); + let html = serialize_html(&doc); + assert!(html.contains(r#"type="text""#), "missing type attr"); + assert!(html.contains(r#"name="field""#), "missing name attr"); + assert!(html.contains(r#"value="hello""#), "missing value attr"); + } + + // -- Text escaping ------------------------------------------------------- + + #[test] + fn test_text_escaping() { + let doc = parse_html("<html><body><p>a &amp; b &lt; c &gt; d</p></body></html>").unwrap(); + let html = serialize_html(&doc); + // The serializer should re-escape special characters in text + assert!( + html.contains("&amp;") && html.contains("&lt;") && html.contains("&gt;"), + "expected escaped entities in text, got: {html}" + ); + } + + // -- Comments ------------------------------------------------------------ + + #[test] + fn test_comment_preserved() { + let doc = parse_html("<html><body><!-- comment --></body></html>").unwrap(); + let html = serialize_html(&doc); + assert!( + html.contains("<!-- comment -->"), + "comment should be preserved, got: {html}" + ); + } + + // -- DOCTYPE ------------------------------------------------------------- + + #[test] + fn test_doctype_serialization() { + let doc = parse_html( + r#"<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd"><html><body></body></html>"#, + ) + .unwrap(); + let html = serialize_html(&doc); + assert!( + html.contains("<!DOCTYPE html"), + "expected DOCTYPE, got: {html}" + ); + assert!( + html.contains("PUBLIC"), + "expected PUBLIC in DOCTYPE, got: {html}" + ); + } + + // -- Charset / encoding -------------------------------------------------- + + #[test] + fn test_meta_charset_utf8() { + let doc = + parse_html(r#"<html><head><meta charset="utf-8"></head><body>caf&#233;</body></html>"#) + .unwrap(); + let html = serialize_html(&doc); + // With UTF-8 charset declared, non-ASCII should be preserved as UTF-8 + assert!( + html.contains("café") || html.contains("caf"), + "UTF-8 content should be preserved, got: {html}" + ); + } + + // -- Block/inline formatting --------------------------------------------- + + #[test] + fn test_inline_element_no_newlines() { + let doc = parse_html("<html><body><p>Hello <span>world</span></p></body></html>").unwrap(); + let html = serialize_html(&doc); + // Inline elements like span should not have extra formatting newlines + assert!( + html.contains("<span>world</span>"), + "inline element should not have extra newlines, got: {html}" + ); + } + + // -- Trailing newline ---------------------------------------------------- + + #[test] + fn test_trailing_newline() { + let doc = parse_html("<html><body></body></html>").unwrap(); + let html = serialize_html(&doc); + assert!( + html.ends_with('\n'), + "output should end with newline, got: {html:?}" + ); + } + + // -- Nested elements ----------------------------------------------------- + + #[test] + fn test_nested_elements() { + let doc = + parse_html("<html><body><div><ul><li>one</li><li>two</li></ul></div></body></html>") + .unwrap(); + let html = serialize_html(&doc); + assert!(html.contains("<ul>"), "missing <ul>"); + assert!(html.contains("<li>one</li>"), "missing first li"); + assert!(html.contains("<li>two</li>"), "missing second li"); + assert!(html.contains("</ul>"), "missing </ul>"); + } + + // -- Entity references --------------------------------------------------- + + #[test] + fn test_entity_ref_serialization() { + // Build a document manually with an entity reference node + let mut doc = Document::new(); + let root = doc.root(); + let html_id = doc.create_node(NodeKind::Element { + name: "html".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + doc.append_child(root, html_id); + let body_id = doc.create_node(NodeKind::Element { + name: "body".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + doc.append_child(html_id, body_id); + let entity_id = doc.create_node(NodeKind::EntityRef { + name: "nbsp".to_string(), + value: None, + }); + doc.append_child(body_id, entity_id); + let html = serialize_html(&doc); + assert!( + html.contains("&nbsp;"), + "entity reference should be preserved, got: {html}" + ); + } + + // -- Full document roundtrip --------------------------------------------- + + #[test] + fn test_full_html_document() { + let input = + "<html><head><title>Test</title></head><body><h1>Hello</h1><p>World</p></body></html>"; + let doc = parse_html(input).unwrap(); + let html = serialize_html(&doc); + assert!(html.contains("<html>"), "missing <html>"); + assert!(html.contains("<head>"), "missing <head>"); + assert!(html.contains("<title>Test</title>"), "missing title"); + assert!(html.contains("<body>"), "missing <body>"); + assert!(html.contains("Hello"), "missing h1 content"); + assert!(html.contains("World"), "missing p content"); + assert!(html.contains("</html>"), "missing </html>"); + } + + // -- URI attribute encoding ---------------------------------------------- + + #[test] + fn test_uri_attribute_space() { + let doc = parse_html(r#"<html><body><a href="a b">link</a></body></html>"#).unwrap(); + let html = serialize_html(&doc); + assert!( + html.contains("a%20b"), + "spaces in href should be encoded as %20, got: {html}" + ); + } + + // -- Attribute with quotes ----------------------------------------------- + + #[test] + fn test_attr_with_quotes() { + // Build manually to control the attribute value precisely + let mut doc = Document::new(); + let root = doc.root(); + let html_id = doc.create_node(NodeKind::Element { + name: "html".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + doc.append_child(root, html_id); + let body_id = doc.create_node(NodeKind::Element { + name: "body".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + doc.append_child(html_id, body_id); + let div_id = doc.create_node(NodeKind::Element { + name: "div".to_string(), + prefix: None, + namespace: None, + attributes: vec![crate::tree::Attribute { + name: "title".to_string(), + value: "say \"hello\"".to_string(), + prefix: None, + namespace: None, + raw_value: None, + }], + }); + doc.append_child(body_id, div_id); + let html = serialize_html(&doc); + // When value contains " and not ', should use single-quote delimiters + assert!( + html.contains("title='say \"hello\"'"), + "expected single-quoted attr, got: {html}" + ); + } + + // -- HTML5 serializer ---------------------------------------------------- + + #[test] + fn test_html5_basic_roundtrip() { + let doc = crate::html5::parse_html5("<p>Hello</p>").unwrap(); + let html = serialize_html5(&doc); + assert!(html.contains("<p>Hello</p>"), "got: {html}"); + assert!(html.contains("<html>"), "got: {html}"); + } + + #[test] + fn test_html5_void_elements() { + let doc = crate::html5::parse_html5("<br><hr><img src=\"x.png\">").unwrap(); + let html = serialize_html5(&doc); + assert!(html.contains("<br>"), "got: {html}"); + assert!(!html.contains("</br>"), "void should not close: {html}"); + assert!(html.contains("<hr>"), "got: {html}"); + assert!(html.contains("<img"), "got: {html}"); + } + + #[test] + fn test_html5_raw_text() { + let doc = crate::html5::parse_html5("<script>if (a < b) {}</script>").unwrap(); + let html = serialize_html5(&doc); + assert!( + html.contains("if (a < b) {}"), + "script content should not be escaped: {html}" + ); + } + + #[test] + fn test_html5_preserves_utf8() { + let doc = crate::html5::parse_html5("<p>café</p>").unwrap(); + let html = serialize_html5(&doc); + assert!(html.contains("café"), "UTF-8 should be preserved: {html}"); + } + + #[test] + fn test_html5_foreign_self_closing() { + let doc = + crate::html5::parse_html5("<svg><circle cx=\"50\" cy=\"50\" r=\"40\"/></svg>").unwrap(); + let html = serialize_html5(&doc); + assert!(html.contains("<circle"), "got: {html}"); + // Foreign empty elements should use self-closing syntax + assert!( + html.contains("/>"), + "foreign empty element should self-close: {html}" + ); + } +} diff --git a/browser/vendor/xmloxide/src/serial/mod.rs b/browser/vendor/xmloxide/src/serial/mod.rs new file mode 100644 index 000000000..e54216a97 --- /dev/null +++ b/browser/vendor/xmloxide/src/serial/mod.rs @@ -0,0 +1,12 @@ +//! XML and HTML serialization. +//! +//! This module serializes a `Document` tree back to XML (or HTML) text. +//! The serializer handles proper escaping, XML declarations, and +//! formatting options. Includes Canonical XML (C14N) serialization for +//! producing deterministic byte sequences required by XML digital signatures. + +pub mod c14n; +pub mod html; +pub mod xml; + +pub use xml::{serialize, serialize_with_options, SerializeOptions}; diff --git a/browser/vendor/xmloxide/src/serial/xml.rs b/browser/vendor/xmloxide/src/serial/xml.rs new file mode 100644 index 000000000..f73832c71 --- /dev/null +++ b/browser/vendor/xmloxide/src/serial/xml.rs @@ -0,0 +1,768 @@ +//! XML serializer. +//! +//! Serializes a `Document` tree into a well-formed XML string. + +use crate::tree::{Document, NodeId, NodeKind}; + +/// Options controlling XML serialization output. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::Document; +/// use xmloxide::serial::{serialize_with_options, SerializeOptions}; +/// +/// let doc = Document::parse_str("<root><child>Hello</child></root>").unwrap(); +/// let xml = serialize_with_options(&doc, &SerializeOptions::default().indent(true)); +/// assert!(xml.contains(" <child>")); +/// ``` +#[derive(Debug, Clone)] +pub struct SerializeOptions { + /// Whether to produce indented (pretty-printed) output. + /// Defaults to `false`. + pub indent: bool, + /// The indentation string used for each level when `indent` is `true`. + /// Defaults to two spaces. + pub indent_str: String, +} + +impl Default for SerializeOptions { + fn default() -> Self { + Self { + indent: false, + indent_str: " ".to_string(), + } + } +} + +impl SerializeOptions { + /// Enables or disables indented (pretty-printed) output. + /// + /// When enabled, child elements are placed on their own lines with + /// indentation (two spaces per level by default). Mixed-content elements + /// (those containing both text and element children) are not indented. + /// Use [`indent_str`](Self::indent_str) to customize the indentation + /// string. Disabled by default. + #[must_use] + pub fn indent(mut self, indent: bool) -> Self { + self.indent = indent; + self + } + + /// Sets the indentation string used for each nesting level. + /// + /// The default is two spaces (`" "`). Common alternatives include a tab + /// (`"\t"`) or four spaces (`" "`). This only takes effect when + /// [`indent`](Self::indent) is enabled. + #[must_use] + pub fn indent_str(mut self, s: &str) -> Self { + self.indent_str = s.to_string(); + self + } +} + +/// Serializes a document to an XML string. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::Document; +/// use xmloxide::serial::serialize; +/// +/// let doc = Document::parse_str("<root><child>Hello</child></root>").unwrap(); +/// let xml = serialize(&doc); +/// assert!(xml.contains("<root>")); +/// ``` +#[must_use] +pub fn serialize(doc: &Document) -> String { + serialize_with_options(doc, &SerializeOptions::default()) +} + +/// Serializes a document to an XML string with the given options. +/// +/// When `options.indent` is `true`, produces pretty-printed output with +/// newlines and indentation between elements. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::Document; +/// use xmloxide::serial::{serialize_with_options, SerializeOptions}; +/// +/// let doc = Document::parse_str("<root><child>Hello</child></root>").unwrap(); +/// let xml = serialize_with_options(&doc, &SerializeOptions::default().indent(true)); +/// assert!(xml.contains(" <child>")); +/// ``` +#[must_use] +pub fn serialize_with_options(doc: &Document, options: &SerializeOptions) -> String { + let mut output = String::new(); + + // XML declaration — always emit, defaulting to version 1.0 (matches libxml2) + let version = doc.version.as_deref().unwrap_or("1.0"); + output.push_str("<?xml version=\""); + output.push_str(version); + output.push('"'); + if let Some(ref encoding) = doc.encoding { + output.push_str(" encoding=\""); + output.push_str(encoding); + output.push('"'); + } + if let Some(standalone) = doc.standalone { + output.push_str(" standalone=\""); + output.push_str(if standalone { "yes" } else { "no" }); + output.push('"'); + } + output.push_str("?>\n"); + + // When no encoding is declared, non-ASCII chars in attributes are + // re-encoded as hex character references (matches libxml2 behavior). + let reencode_non_ascii = doc.encoding.is_none(); + + // Serialize children of the document root + for child in doc.children(doc.root()) { + serialize_node( + doc, + child, + &mut output, + reencode_non_ascii, + options, + 0, + false, + ); + } + + // Trailing newline (matches libxml2 output convention) + output.push('\n'); + + output +} + +/// Returns `true` if the element contains only other elements (and optional +/// whitespace text), meaning it's safe to add indentation. +fn is_element_only(doc: &Document, id: NodeId) -> bool { + let mut has_element_child = false; + for child in doc.children(id) { + match &doc.node(child).kind { + NodeKind::Element { .. } => has_element_child = true, + NodeKind::Text { content } if !content.trim().is_empty() => { + return false; + } + NodeKind::CData { .. } | NodeKind::EntityRef { .. } => return false, + _ => {} + } + } + has_element_child +} + +#[allow(clippy::too_many_lines)] +fn serialize_node( + doc: &Document, + id: NodeId, + out: &mut String, + reencode_non_ascii: bool, + options: &SerializeOptions, + depth: usize, + parent_is_element_only: bool, +) { + let indent = options.indent; + match &doc.node(id).kind { + NodeKind::Element { + name, + prefix, + attributes, + .. + } => { + if indent && parent_is_element_only { + for _ in 0..depth { + out.push_str(&options.indent_str); + } + } + out.push('<'); + if let Some(pfx) = prefix { + out.push_str(pfx); + out.push(':'); + } + out.push_str(name); + + for attr in attributes { + out.push(' '); + if let Some(pfx) = &attr.prefix { + out.push_str(pfx); + out.push(':'); + } + out.push_str(&attr.name); + out.push_str("=\""); + let is_ns_decl = attr.name == "xmlns" || attr.prefix.as_deref() == Some("xmlns"); + if let Some(raw) = attr.raw_value.as_ref().filter(|_| !is_ns_decl) { + write_escaped_attr_preserve_refs(out, raw, reencode_non_ascii); + } else { + write_escaped_attr(out, &attr.value, reencode_non_ascii); + } + out.push('"'); + } + + if doc.first_child(id).is_none() { + out.push_str("/>"); + if indent && parent_is_element_only { + out.push('\n'); + } + } else { + out.push('>'); + let element_only = indent && is_element_only(doc, id); + if element_only { + out.push('\n'); + } + for child in doc.children(id) { + if element_only { + if let NodeKind::Text { content } = &doc.node(child).kind { + if content.trim().is_empty() { + continue; + } + } + } + serialize_node( + doc, + child, + out, + reencode_non_ascii, + options, + depth + 1, + element_only, + ); + } + if element_only { + for _ in 0..depth { + out.push_str(&options.indent_str); + } + } + out.push_str("</"); + if let Some(pfx) = prefix { + out.push_str(pfx); + out.push(':'); + } + out.push_str(name); + out.push('>'); + if indent && parent_is_element_only { + out.push('\n'); + } + } + } + NodeKind::Text { content } => { + write_escaped_text(out, content, reencode_non_ascii); + } + NodeKind::CData { content } => { + out.push_str("<![CDATA["); + out.push_str(content); + out.push_str("]]>"); + } + NodeKind::Comment { content } => { + if indent && parent_is_element_only { + for _ in 0..depth { + out.push_str(&options.indent_str); + } + } + out.push_str("<!--"); + out.push_str(content); + out.push_str("-->"); + if indent && parent_is_element_only { + out.push('\n'); + } + } + NodeKind::ProcessingInstruction { target, data } => { + if indent && parent_is_element_only { + for _ in 0..depth { + out.push_str(&options.indent_str); + } + } + out.push_str("<?"); + out.push_str(target); + if let Some(d) = data { + out.push(' '); + out.push_str(d); + } + out.push_str("?>"); + if indent && parent_is_element_only { + out.push('\n'); + } + } + NodeKind::EntityRef { name, .. } => { + out.push('&'); + out.push_str(name); + out.push(';'); + } + NodeKind::DocumentType { + name, + system_id, + public_id, + internal_subset, + } => { + out.push_str("<!DOCTYPE "); + out.push_str(name); + match (public_id, system_id) { + (Some(pub_id), Some(sys_id)) => { + out.push_str(" PUBLIC \""); + out.push_str(pub_id); + out.push_str("\" \""); + out.push_str(sys_id); + out.push('"'); + } + (None, Some(sys_id)) => { + out.push_str(" SYSTEM \""); + out.push_str(sys_id); + out.push('"'); + } + _ => {} + } + if let Some(ref subset) = internal_subset { + out.push_str(" ["); + out.push_str(subset); + out.push_str("]>"); + } else { + out.push('>'); + } + } + NodeKind::Document => { + // Should not appear as a child node + } + } +} + +/// Writes a hexadecimal character reference (`&#xHH;`) for a Unicode code point. +fn write_hex_char_ref(out: &mut String, ch: char) { + use std::fmt::Write; + let _ = write!(out, "&#x{:X};", ch as u32); +} + +/// Escapes text content for XML output. +/// +/// Follows libxml2's `xmlEscapeEntities` behavior: +/// - `<`, `>`, `&` are escaped with named entity references +/// - `\r` is encoded as `&#13;` +/// - `\t` and `\n` are passed through +/// - Control characters below 0x20 (other than `\t`, `\n`, `\r`) are hex-encoded +/// - Non-ASCII characters are passed through as raw UTF-8 +fn write_escaped_text(out: &mut String, text: &str, reencode_non_ascii: bool) { + for ch in text.chars() { + match ch { + '&' => out.push_str("&amp;"), + '<' => out.push_str("&lt;"), + '>' => out.push_str("&gt;"), + '\r' => out.push_str("&#13;"), + '\t' | '\n' => out.push(ch), + c if (c as u32) < 0x20 => write_hex_char_ref(out, c), + c if reencode_non_ascii && (c as u32) >= 0x80 => write_hex_char_ref(out, c), + _ => out.push(ch), + } + } +} + +/// Escapes an attribute value that contains preserved entity references. +/// +/// Custom entity references (`&name;` where name is not a builtin) are +/// preserved as-is. Character references (`&#...;`) and builtin entity refs +/// (`&amp;`, `&lt;`, `&gt;`, `&apos;`, `&quot;`) are decoded to their +/// actual characters and then re-escaped normally through the attribute +/// escaping logic. This matches libxml2's serialization behavior. +fn write_escaped_attr_preserve_refs(out: &mut String, text: &str, reencode_non_ascii: bool) { + let bytes = text.as_bytes(); + let len = bytes.len(); + let mut i = 0; + + while i < len { + let b = bytes[i]; + if b == b'&' { + // Check if this starts a valid reference + if let Some(ref_end) = find_attr_reference_end(bytes, i) { + let ref_str = &text[i..=ref_end]; + if ref_str.starts_with("&#") { + // Character reference — decode to char, then escape normally + if let Some(ch) = decode_char_ref(ref_str) { + write_escaped_attr_char(out, ch, reencode_non_ascii); + } else { + out.push_str(ref_str); + } + } else if let Some(ch) = decode_builtin_entity_ref(ref_str) { + // Builtin entity ref — decode and re-escape normally + write_escaped_attr_char(out, ch, reencode_non_ascii); + } else { + // Custom entity reference — preserve as-is + out.push_str(ref_str); + } + i = ref_end + 1; + continue; + } + out.push_str("&amp;"); + i += 1; + } else if b == b'<' { + out.push_str("&lt;"); + i += 1; + } else if b == b'>' { + out.push_str("&gt;"); + i += 1; + } else if b == b'"' { + out.push_str("&quot;"); + i += 1; + } else if b == b'\t' { + out.push_str("&#9;"); + i += 1; + } else if b == b'\n' { + out.push_str("&#10;"); + i += 1; + } else if b == b'\r' { + out.push_str("&#13;"); + i += 1; + } else { + let ch = &text[i..]; + if let Some(c) = ch.chars().next() { + if (c as u32) < 0x20 || (reencode_non_ascii && (c as u32) >= 0x80) { + write_hex_char_ref(out, c); + } else { + out.push(c); + } + i += c.len_utf8(); + } else { + i += 1; + } + } + } +} + +/// Decodes a character reference (`&#NNN;` or `&#xHHH;`) to a `char`. +fn decode_char_ref(s: &str) -> Option<char> { + let inner = s.strip_prefix("&#")?.strip_suffix(';')?; + let code_point = if let Some(hex) = inner.strip_prefix('x') { + u32::from_str_radix(hex, 16).ok()? + } else { + inner.parse::<u32>().ok()? + }; + char::from_u32(code_point) +} + +/// Decodes a builtin entity reference to a `char`, if it is one. +/// Returns `None` for custom (non-builtin) entity references. +fn decode_builtin_entity_ref(s: &str) -> Option<char> { + match s { + "&amp;" => Some('&'), + "&lt;" => Some('<'), + "&gt;" => Some('>'), + "&apos;" => Some('\''), + "&quot;" => Some('"'), + _ => None, + } +} + +/// Writes a single character with attribute escaping rules. +fn write_escaped_attr_char(out: &mut String, ch: char, reencode_non_ascii: bool) { + match ch { + '&' => out.push_str("&amp;"), + '<' => out.push_str("&lt;"), + '>' => out.push_str("&gt;"), + '"' => out.push_str("&quot;"), + '\t' => out.push_str("&#9;"), + '\n' => out.push_str("&#10;"), + '\r' => out.push_str("&#13;"), + c if (c as u32) < 0x20 => write_hex_char_ref(out, c), + c if reencode_non_ascii && (c as u32) >= 0x80 => write_hex_char_ref(out, c), + _ => out.push(ch), + } +} + +/// Finds the end of an entity/character reference in attribute raw value. +fn find_attr_reference_end(bytes: &[u8], start: usize) -> Option<usize> { + if start >= bytes.len() || bytes[start] != b'&' { + return None; + } + let mut i = start + 1; + if i >= bytes.len() { + return None; + } + if bytes[i] == b'#' { + i += 1; + if i >= bytes.len() { + return None; + } + if bytes[i] == b'x' { + i += 1; + let d = i; + while i < bytes.len() && bytes[i].is_ascii_hexdigit() { + i += 1; + } + if i == d || i >= bytes.len() || bytes[i] != b';' { + return None; + } + } else { + let d = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + if i == d || i >= bytes.len() || bytes[i] != b';' { + return None; + } + } + Some(i) + } else { + if !bytes[i].is_ascii_alphabetic() && bytes[i] != b'_' && bytes[i] != b':' { + return None; + } + i += 1; + while i < bytes.len() + && (bytes[i].is_ascii_alphanumeric() + || bytes[i] == b'_' + || bytes[i] == b':' + || bytes[i] == b'-' + || bytes[i] == b'.') + { + i += 1; + } + if i >= bytes.len() || bytes[i] != b';' { + return None; + } + Some(i) + } +} + +/// Escapes attribute values for XML output. +/// +/// Follows libxml2's `xmlAttrSerializeTxtContent` behavior: +/// - `<`, `>`, `&`, `"` are escaped with named entity references +/// - `\t` → `&#9;`, `\n` → `&#10;`, `\r` → `&#13;` +/// - When `reencode_non_ascii` is true (doc has no declared encoding), non-ASCII +/// characters (>= U+0080) are encoded as hex character references +fn write_escaped_attr(out: &mut String, text: &str, reencode_non_ascii: bool) { + for ch in text.chars() { + match ch { + '&' => out.push_str("&amp;"), + '<' => out.push_str("&lt;"), + '>' => out.push_str("&gt;"), + '"' => out.push_str("&quot;"), + '\t' => out.push_str("&#9;"), + '\n' => out.push_str("&#10;"), + '\r' => out.push_str("&#13;"), + c if (c as u32) < 0x20 => write_hex_char_ref(out, c), + c if reencode_non_ascii && (c as u32) >= 0x80 => write_hex_char_ref(out, c), + _ => out.push(ch), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::tree::Attribute; + + #[test] + fn test_serialize_empty_element() { + let mut doc = Document::new(); + let root = doc.root(); + let elem = doc.create_node(NodeKind::Element { + name: "br".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + doc.append_child(root, elem); + assert_eq!(serialize(&doc), "<?xml version=\"1.0\"?>\n<br/>\n"); + } + + #[test] + fn test_serialize_element_with_text() { + let mut doc = Document::new(); + let root = doc.root(); + let elem = doc.create_node(NodeKind::Element { + name: "p".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + let text = doc.create_node(NodeKind::Text { + content: "Hello".to_string(), + }); + doc.append_child(root, elem); + doc.append_child(elem, text); + assert_eq!(serialize(&doc), "<?xml version=\"1.0\"?>\n<p>Hello</p>\n"); + } + + #[test] + fn test_serialize_element_with_attributes() { + let mut doc = Document::new(); + let root = doc.root(); + let elem = doc.create_node(NodeKind::Element { + name: "div".to_string(), + prefix: None, + namespace: None, + attributes: vec![ + Attribute { + name: "id".to_string(), + value: "main".to_string(), + prefix: None, + namespace: None, + raw_value: None, + }, + Attribute { + name: "class".to_string(), + value: "big".to_string(), + prefix: None, + namespace: None, + raw_value: None, + }, + ], + }); + doc.append_child(root, elem); + assert_eq!( + serialize(&doc), + "<?xml version=\"1.0\"?>\n<div id=\"main\" class=\"big\"/>\n" + ); + } + + #[test] + fn test_serialize_escaping() { + let mut doc = Document::new(); + let root = doc.root(); + let elem = doc.create_node(NodeKind::Element { + name: "p".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + let text = doc.create_node(NodeKind::Text { + content: "a < b & c > d".to_string(), + }); + doc.append_child(root, elem); + doc.append_child(elem, text); + assert_eq!( + serialize(&doc), + "<?xml version=\"1.0\"?>\n<p>a &lt; b &amp; c &gt; d</p>\n" + ); + } + + #[test] + fn test_serialize_comment() { + let mut doc = Document::new(); + let root = doc.root(); + let comment = doc.create_node(NodeKind::Comment { + content: " a comment ".to_string(), + }); + doc.append_child(root, comment); + assert_eq!( + serialize(&doc), + "<?xml version=\"1.0\"?>\n<!-- a comment -->\n" + ); + } + + #[test] + fn test_serialize_cdata() { + let mut doc = Document::new(); + let root = doc.root(); + let elem = doc.create_node(NodeKind::Element { + name: "script".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + let cdata = doc.create_node(NodeKind::CData { + content: "x < 1 && y > 2".to_string(), + }); + doc.append_child(root, elem); + doc.append_child(elem, cdata); + assert_eq!( + serialize(&doc), + "<?xml version=\"1.0\"?>\n<script><![CDATA[x < 1 && y > 2]]></script>\n" + ); + } + + #[test] + fn test_serialize_processing_instruction() { + let mut doc = Document::new(); + let root = doc.root(); + let pi = doc.create_node(NodeKind::ProcessingInstruction { + target: "xml-stylesheet".to_string(), + data: Some("type=\"text/css\" href=\"style.css\"".to_string()), + }); + doc.append_child(root, pi); + assert_eq!( + serialize(&doc), + "<?xml version=\"1.0\"?>\n<?xml-stylesheet type=\"text/css\" href=\"style.css\"?>\n" + ); + } + + #[test] + fn test_serialize_xml_declaration() { + let mut doc = Document::new(); + doc.version = Some("1.0".to_string()); + doc.encoding = Some("UTF-8".to_string()); + let root = doc.root(); + let elem = doc.create_node(NodeKind::Element { + name: "root".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + doc.append_child(root, elem); + assert_eq!( + serialize(&doc), + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root/>\n" + ); + } + + #[test] + fn test_serialize_attr_escaping() { + let mut doc = Document::new(); + let root = doc.root(); + let elem = doc.create_node(NodeKind::Element { + name: "a".to_string(), + prefix: None, + namespace: None, + attributes: vec![Attribute { + name: "title".to_string(), + value: "He said \"hello\" & <bye>".to_string(), + prefix: None, + namespace: None, + raw_value: None, + }], + }); + doc.append_child(root, elem); + assert_eq!( + serialize(&doc), + "<?xml version=\"1.0\"?>\n<a title=\"He said &quot;hello&quot; &amp; &lt;bye&gt;\"/>\n" + ); + } + + #[test] + fn test_serialize_pretty_print() { + let doc = Document::parse_str("<root><child><inner>text</inner></child></root>").unwrap(); + let opts = SerializeOptions::default().indent(true); + let xml = serialize_with_options(&doc, &opts); + assert_eq!( + xml, + "<?xml version=\"1.0\"?>\n<root>\n <child>\n <inner>text</inner>\n </child>\n</root>\n" + ); + } + + #[test] + fn test_serialize_pretty_print_mixed_content() { + // Mixed content (element + non-whitespace text) should not be indented + let doc = Document::parse_str("<root><p>Hello <b>world</b></p></root>").unwrap(); + let opts = SerializeOptions::default().indent(true); + let xml = serialize_with_options(&doc, &opts); + // Mixed content children are not indented + assert!(xml.contains(" <p>Hello <b>world</b></p>")); + } + + #[test] + fn test_serialize_pretty_print_custom_indent() { + let doc = Document::parse_str("<root><child/></root>").unwrap(); + let opts = SerializeOptions::default().indent(true).indent_str("\t"); + let xml = serialize_with_options(&doc, &opts); + assert!(xml.contains("\t<child/>")); + } + + #[test] + fn test_serialize_no_indent_unchanged() { + // Default (no indent) should produce same output as serialize() + let doc = Document::parse_str("<root><child>Hello</child></root>").unwrap(); + let xml1 = serialize(&doc); + let xml2 = serialize_with_options(&doc, &SerializeOptions::default()); + assert_eq!(xml1, xml2); + } +} diff --git a/browser/vendor/xmloxide/src/tree/mod.rs b/browser/vendor/xmloxide/src/tree/mod.rs new file mode 100644 index 000000000..cdfbec501 --- /dev/null +++ b/browser/vendor/xmloxide/src/tree/mod.rs @@ -0,0 +1,2197 @@ +//! Arena-based XML document tree. +//! +//! This module implements the core tree representation using arena allocation +//! with typed indices. All nodes live in a contiguous `Vec<NodeData>` owned by +//! the `Document`, and are referenced by `NodeId` — a newtype over `NonZeroU32`. +//! +//! This design provides O(1) node access, cache-friendly layout, no reference +//! counting overhead, and safe bulk deallocation (drop the `Document` and +//! everything is freed). +//! +//! # Architecture +//! +//! Unlike libxml2's web of raw C pointers, we use arena indices for all +//! navigation links (parent, first\_child, last\_child, next\_sibling, +//! prev\_sibling). This avoids borrow checker issues, reference cycles, +//! and per-node heap allocation. + +mod node; + +pub use node::NodeKind; + +use crate::error::{ParseDiagnostic, ParseError}; +use std::collections::HashMap; +use std::num::NonZeroU32; + +/// A typed index into the document's node arena. +/// +/// `NodeId` is a newtype over `NonZeroU32`, meaning it can never be zero +/// and `Option<NodeId>` has the same size as `NodeId` (niche optimization). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[repr(transparent)] +pub struct NodeId(NonZeroU32); + +impl NodeId { + /// Creates a `NodeId` from a raw index. + /// + /// # Panics + /// + /// Panics if `index` is 0. + #[allow(clippy::expect_used, clippy::cast_possible_truncation)] + #[inline] + fn from_index(index: usize) -> Self { + Self(NonZeroU32::new(index as u32).expect("NodeId index must be non-zero")) + } + + /// Returns the raw index as a `usize` for indexing into the arena. + #[inline] + fn as_index(self) -> usize { + self.0.get() as usize + } + + /// Converts this `NodeId` to a raw `u32` for FFI interop. + /// + /// The returned value is always non-zero (valid `NodeId`s start at 1). + /// Use 0 to represent "no node" in FFI code. + #[must_use] + pub fn into_raw(self) -> u32 { + self.0.get() + } + + /// Creates a `NodeId` from a raw `u32`, if non-zero. + /// + /// Returns `None` if `raw` is 0 (which represents "no node" in FFI code). + #[must_use] + pub fn from_raw(raw: u32) -> Option<Self> { + NonZeroU32::new(raw).map(Self) + } +} + +/// Storage for a single node in the document arena. +/// +/// Each node stores its kind (element, text, comment, etc.) and links to +/// parent, children, and siblings for tree navigation. Access individual +/// nodes via [`Document::node`]. +#[derive(Debug, Clone)] +pub struct NodeData { + /// What kind of node this is (element, text, comment, etc.) and its payload. + pub kind: NodeKind, + /// Parent node, if any. The document root node has no parent. + pub parent: Option<NodeId>, + /// First child node. + pub first_child: Option<NodeId>, + /// Last child node (for O(1) append). + pub last_child: Option<NodeId>, + /// Next sibling. + pub next_sibling: Option<NodeId>, + /// Previous sibling. + pub prev_sibling: Option<NodeId>, +} + +impl NodeData { + fn new(kind: NodeKind) -> Self { + Self { + kind, + parent: None, + first_child: None, + last_child: None, + next_sibling: None, + prev_sibling: None, + } + } +} + +/// An XML attribute on an element. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Attribute { + /// The attribute name (the local part, e.g., `"lang"` for `xml:lang`). + pub name: String, + /// The attribute value (fully expanded — entity references resolved). + pub value: String, + /// Namespace prefix, if any (e.g., `"xml"` for `xml:lang`). + pub prefix: Option<String>, + /// Namespace URI after resolution, if any. + pub namespace: Option<String>, + /// The original attribute value text before entity expansion, if it + /// contained entity references. Used for serialization to preserve + /// entity references in the output (matching libxml2 behavior). + pub raw_value: Option<String>, +} + +/// An XML document. +/// +/// The `Document` owns all nodes in an arena and provides methods for +/// tree navigation and mutation. All tree operations go through +/// `&Document` (navigation) or `&mut Document` (mutation). +/// +/// # Examples +/// +/// ``` +/// use xmloxide::Document; +/// +/// let doc = Document::parse_str("<root/>").unwrap(); +/// let root = doc.root_element().unwrap(); +/// assert_eq!(doc.node_name(root), Some("root")); +/// ``` +#[derive(Debug, Clone)] +pub struct Document { + /// The node arena. Index 0 is unused (placeholder for `NonZeroU32`). + nodes: Vec<NodeData>, + /// The document root node id (the Document node, not the root element). + root: NodeId, + /// XML version from the XML declaration (e.g., "1.0"). + pub version: Option<String>, + /// Encoding from the XML declaration (e.g., "UTF-8"). + pub encoding: Option<String>, + /// Standalone flag from the XML declaration. + pub standalone: Option<bool>, + /// Diagnostics collected during parsing (warnings and recovered errors). + pub diagnostics: Vec<ParseDiagnostic>, + /// Mapping from ID attribute values to element nodes. + /// + /// Populated during DTD validation when attributes of type ID are + /// validated. Used by [`element_by_id`](Document::element_by_id) and + /// the `XPath` `id()` function. + id_map: HashMap<String, NodeId>, +} + +impl Document { + /// Creates a new empty document. + /// + /// The document contains a single root Document node. + #[must_use] + pub fn new() -> Self { + Self::with_capacity(64) + } + + /// Creates a new empty document with pre-allocated capacity for `n` nodes. + #[must_use] + pub fn with_capacity(n: usize) -> Self { + let mut nodes = Vec::with_capacity(n); + // Index 0: placeholder (NodeId uses NonZeroU32) + nodes.push(NodeData::new(NodeKind::Document)); + // Index 1: the document root node + nodes.push(NodeData::new(NodeKind::Document)); + let root = NodeId::from_index(1); + Self { + nodes, + root, + version: None, + encoding: None, + standalone: None, + diagnostics: Vec::new(), + id_map: HashMap::new(), + } + } + + /// Parses an XML string into a `Document`. + /// + /// # Errors + /// + /// Returns `ParseError` if the input is not well-formed XML. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::Document; + /// + /// let doc = Document::parse_str("<root><child/></root>").unwrap(); + /// ``` + pub fn parse_str(input: &str) -> Result<Self, ParseError> { + // Strip leading UTF-8 BOM (U+FEFF) if present — per XML 1.0 §4.3.3, + // the BOM is used for encoding detection and should be ignored. + let had_bom = input.starts_with('\u{FEFF}'); + let input = input.strip_prefix('\u{FEFF}').unwrap_or(input); + + // Check encoding declaration compatibility. + if let Some(enc) = Self::extract_encoding_from_decl(input) { + let enc_lower = enc.to_ascii_lowercase(); + if had_bom && enc_lower != "utf-8" { + // UTF-8 BOM present but encoding is not UTF-8. + return Err(crate::error::ParseError { + message: format!("UTF-8 BOM present but encoding declared as '{enc}'"), + location: crate::error::SourceLocation::default(), + diagnostics: Vec::new(), + }); + } + // Reject clearly incompatible encodings (multi-byte or + // non-ASCII-compatible). UTF-8, US-ASCII, and single-byte + // ISO-8859 variants are compatible with UTF-8 text. + let incompatible = enc_lower.starts_with("utf-16") + || enc_lower.starts_with("utf-32") + || enc_lower.starts_with("ucs") + || enc_lower == "ebcdic"; + if incompatible { + return Err(crate::error::ParseError { + message: format!( + "encoding declaration '{enc}' is incompatible with actual encoding" + ), + location: crate::error::SourceLocation::default(), + diagnostics: Vec::new(), + }); + } + // Reject encoding names that are not recognized by encoding_rs + // (excluding UTF-8 and ASCII which are always valid for Rust strings). + if enc_lower != "utf-8" + && enc_lower != "us-ascii" + && enc_lower != "ascii" + && encoding_rs::Encoding::for_label(enc.as_bytes()).is_none() + { + return Err(crate::error::ParseError { + message: format!("unsupported encoding '{enc}'"), + location: crate::error::SourceLocation::default(), + diagnostics: Vec::new(), + }); + } + } + + crate::parser::parse_str(input) + } + + /// Extracts the encoding value from an XML declaration, if present. + fn extract_encoding_from_decl(input: &str) -> Option<String> { + let trimmed = input.trim_start(); + if !trimmed.starts_with("<?xml") { + return None; + } + // Find the end of the XML declaration + let decl_end = trimmed.find("?>")?; + let decl = &trimmed[..decl_end]; + + // Look for encoding="..." or encoding='...' + let enc_pos = decl.find("encoding")?; + let after_enc = &decl[enc_pos + 8..].trim_start(); + let after_eq = after_enc.strip_prefix('=')?.trim_start(); + let quote = after_eq.chars().next()?; + if quote != '"' && quote != '\'' { + return None; + } + let value_start = 1; + let value_end = after_eq[value_start..].find(quote)?; + Some(after_eq[value_start..value_start + value_end].to_string()) + } + + /// Parses XML from raw bytes, detecting encoding automatically. + /// + /// Uses BOM sniffing and XML declaration inspection to determine the + /// encoding, then transcodes to UTF-8 before parsing. See + /// [`crate::encoding::decode_to_utf8`] for the full detection pipeline. + /// + /// # Errors + /// + /// Returns `ParseError` if the encoding cannot be determined, the bytes + /// cannot be transcoded, or the resulting XML is not well-formed. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::Document; + /// + /// let doc = Document::parse_bytes(b"<root/>").unwrap(); + /// let root = doc.root_element().unwrap(); + /// assert_eq!(doc.node_name(root), Some("root")); + /// ``` + pub fn parse_bytes(input: &[u8]) -> Result<Self, ParseError> { + use crate::encoding::decode_to_utf8; + use crate::error::SourceLocation; + + let utf8 = decode_to_utf8(input).map_err(|e| ParseError { + message: e.message, + location: SourceLocation::default(), + diagnostics: Vec::new(), + })?; + + // Skip BOM and encoding checks — decode_to_utf8 already handled + // encoding detection and transcoding. Go directly to the parser. + let text = utf8.strip_prefix('\u{FEFF}').unwrap_or(&utf8); + crate::parser::parse_str(text) + } + + /// Parses an XML file from the filesystem. + /// + /// Reads the file as raw bytes and uses automatic encoding detection + /// (BOM sniffing and XML declaration inspection) before parsing. + /// + /// # Errors + /// + /// Returns `ParseError` if the file cannot be read, the encoding + /// cannot be determined, or the XML is not well-formed. + /// + /// # Examples + /// + /// ```no_run + /// use xmloxide::Document; + /// + /// let doc = Document::parse_file("document.xml").unwrap(); + /// ``` + pub fn parse_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self, ParseError> { + use crate::error::SourceLocation; + + let bytes = std::fs::read(path.as_ref()).map_err(|e| ParseError { + message: format!("failed to read file: {e}"), + location: SourceLocation::default(), + diagnostics: Vec::new(), + })?; + Self::parse_bytes(&bytes) + } + + /// Returns the document root `NodeId`. + /// + /// This is the synthetic `Document` node that sits above the root element, + /// processing instructions, comments, and DOCTYPE in the prolog. To get the + /// root *element*, use [`root_element`](Self::root_element). + #[must_use] + pub fn root(&self) -> NodeId { + self.root + } + + /// Returns the root element of the document (the single top-level element). + /// + /// Returns `None` if the document has no element children. + #[must_use] + pub fn root_element(&self) -> Option<NodeId> { + self.children(self.root) + .find(|&id| matches!(self.node(id).kind, NodeKind::Element { .. })) + } + + /// Returns a reference to the [`NodeData`] for the given node. + /// + /// Use this to inspect a node's [`kind`](NodeData::kind) and navigation + /// links. For common queries, prefer the typed accessors like + /// [`node_name`](Self::node_name) and [`node_text`](Self::node_text). + /// + /// # Panics + /// + /// Panics if `id` does not refer to a valid node in this document. + #[must_use] + #[inline] + pub fn node(&self, id: NodeId) -> &NodeData { + &self.nodes[id.as_index()] + } + + /// Returns a mutable reference to the `NodeData` for the given node. + #[inline] + pub(crate) fn node_mut(&mut self, id: NodeId) -> &mut NodeData { + &mut self.nodes[id.as_index()] + } + + /// Returns `true` if the node is an element. + #[must_use] + #[inline] + pub fn is_element(&self, id: NodeId) -> bool { + matches!(self.node(id).kind, NodeKind::Element { .. }) + } + + /// Returns the local name of a node, if applicable. + /// + /// For elements, this is the tag name (e.g., `"div"`). For processing + /// instructions, this is the target (e.g., `"xml-stylesheet"`). Text, + /// comment, CDATA, and document nodes return `None`. + #[must_use] + #[inline] + pub fn node_name(&self, id: NodeId) -> Option<&str> { + match &self.node(id).kind { + NodeKind::Element { name, .. } + | NodeKind::ProcessingInstruction { target: name, .. } => Some(name), + _ => None, + } + } + + /// Returns the namespace URI of an element node, if any. + /// + /// Non-element nodes always return `None`. Elements that have no namespace + /// declaration in scope also return `None`. + #[must_use] + pub fn node_namespace(&self, id: NodeId) -> Option<&str> { + match &self.node(id).kind { + NodeKind::Element { namespace, .. } => namespace.as_deref(), + _ => None, + } + } + + /// Returns the namespace prefix of an element node, if any. + /// + /// For example, returns `Some("svg")` for `<svg:rect>`. + /// Non-element nodes always return `None`. + #[must_use] + pub fn node_prefix(&self, id: NodeId) -> Option<&str> { + match &self.node(id).kind { + NodeKind::Element { prefix, .. } => prefix.as_deref(), + _ => None, + } + } + + /// Returns the direct text content of a text, comment, CDATA, or PI node. + /// + /// For text, comment, and CDATA nodes, returns their string content. For + /// processing instructions, returns the data portion (after the target). + /// For element nodes, returns `None` — use + /// [`text_content`](Self::text_content) to get the concatenated text of + /// all descendant text nodes. + #[must_use] + pub fn node_text(&self, id: NodeId) -> Option<&str> { + match &self.node(id).kind { + NodeKind::Text { content } + | NodeKind::Comment { content } + | NodeKind::CData { content } => Some(content), + NodeKind::ProcessingInstruction { data, .. } => data.as_deref(), + _ => None, + } + } + + /// Returns the concatenated text content of a node and all its descendants. + /// + /// Recursively collects text from all descendant text and CDATA nodes. + /// For a leaf text node, this is equivalent to [`node_text`](Self::node_text). + /// For an element, this concatenates all nested text content (matching + /// the DOM `textContent` property). + #[must_use] + pub fn text_content(&self, id: NodeId) -> String { + let mut result = String::new(); + self.collect_text(id, &mut result); + result + } + + fn collect_text(&self, id: NodeId, buf: &mut String) { + match &self.node(id).kind { + NodeKind::Text { content } | NodeKind::CData { content } => { + buf.push_str(content); + } + NodeKind::EntityRef { value, .. } => { + // When the entity's replacement text was parsed as content + // (XML 1.0 §4.4), the expansion lives in the children. + // Fall back to the stored replacement value otherwise. + if self.node(id).first_child.is_some() { + for child in self.children(id) { + self.collect_text(child, buf); + } + } else if let Some(val) = value { + buf.push_str(val); + } + } + _ => { + for child in self.children(id) { + self.collect_text(child, buf); + } + } + } + } + + /// Returns the attributes of an element node as a slice. + /// + /// Each [`Attribute`] contains the name, value, optional namespace prefix, + /// and namespace URI. Returns an empty slice for non-element nodes. + #[must_use] + #[inline] + pub fn attributes(&self, id: NodeId) -> &[Attribute] { + match &self.node(id).kind { + NodeKind::Element { attributes, .. } => attributes, + _ => &[], + } + } + + /// Returns the value of an attribute by local name on an element node. + /// + /// Performs a linear scan of the element's attributes. Returns `None` if + /// the attribute is not present or the node is not an element. + #[must_use] + #[inline] + pub fn attribute(&self, id: NodeId, name: &str) -> Option<&str> { + self.attributes(id) + .iter() + .find(|a| a.name == name) + .map(|a| a.value.as_str()) + } + + // --- ID lookup --- + + /// Associates an ID value with an element node. + /// + /// Called during DTD validation when an attribute of type ID is found. + /// Subsequent calls to [`element_by_id`](Document::element_by_id) will + /// return the associated node. + pub fn set_id(&mut self, id: &str, node: NodeId) { + self.id_map.insert(id.to_string(), node); + } + + /// Looks up an element by its ID attribute value. + /// + /// Returns the `NodeId` of the element that was registered with + /// [`set_id`](Document::set_id) for the given ID string, or `None` + /// if no such ID exists. + #[must_use] + pub fn element_by_id(&self, id: &str) -> Option<NodeId> { + self.id_map.get(id).copied() + } + + // --- Navigation --- + + /// Returns the parent of a node, or `None` for the document root. + #[must_use] + #[inline] + pub fn parent(&self, id: NodeId) -> Option<NodeId> { + self.node(id).parent + } + + /// Returns the first child of a node, or `None` if it has no children. + #[inline] + #[must_use] + pub fn first_child(&self, id: NodeId) -> Option<NodeId> { + self.node(id).first_child + } + + /// Returns the last child of a node, or `None` if it has no children. + #[inline] + #[must_use] + pub fn last_child(&self, id: NodeId) -> Option<NodeId> { + self.node(id).last_child + } + + /// Returns the next sibling of a node, or `None` if it is the last child. + #[inline] + #[must_use] + pub fn next_sibling(&self, id: NodeId) -> Option<NodeId> { + self.node(id).next_sibling + } + + /// Returns the previous sibling of a node, or `None` if it is the first child. + #[inline] + #[must_use] + pub fn prev_sibling(&self, id: NodeId) -> Option<NodeId> { + self.node(id).prev_sibling + } + + /// Returns an iterator over the direct children of a node. + /// + /// Yields each child `NodeId` in document order (first child to last). + /// For a depth-first traversal that includes nested descendants, use + /// [`descendants`](Self::descendants). + pub fn children(&self, id: NodeId) -> Children<'_> { + Children { + doc: self, + next: self.node(id).first_child, + } + } + + /// Returns an iterator over a node and its ancestors, walking up to the + /// document root. + /// + /// The first item yielded is `id` itself, followed by its parent, then + /// grandparent, and so on up to the document root node. + pub fn ancestors(&self, id: NodeId) -> Ancestors<'_> { + Ancestors { + doc: self, + next: Some(id), + } + } + + /// Returns an iterator over all descendants of a node in depth-first + /// (pre-order) traversal. + /// + /// Does *not* yield `id` itself — only its children, grandchildren, etc. + /// For iterating only the direct children, use + /// [`children`](Self::children). + pub fn descendants(&self, id: NodeId) -> Descendants<'_> { + Descendants { + doc: self, + root: id, + next: self.first_child(id), + } + } + + // --- Mutation --- + + /// Allocates a new node in the arena and returns its `NodeId`. + /// + /// The new node is detached (has no parent). Use + /// [`append_child`](Self::append_child), + /// [`prepend_child`](Self::prepend_child), or + /// [`insert_before`](Self::insert_before) to attach it to the tree. + pub fn create_node(&mut self, kind: NodeKind) -> NodeId { + let index = self.nodes.len(); + self.nodes.push(NodeData::new(kind)); + NodeId::from_index(index) + } + + /// Appends a child node to the end of a parent's child list. + /// + /// The child becomes the new [`last_child`](Self::last_child) of `parent`. + /// If the parent had no children, the child also becomes the + /// [`first_child`](Self::first_child). + /// + /// # Panics + /// + /// Panics (debug-only) if `child` already has a parent. Call + /// [`detach`](Self::detach) first to re-parent an existing node. + pub fn append_child(&mut self, parent: NodeId, child: NodeId) { + debug_assert!( + self.node(child).parent.is_none(), + "child already has a parent; detach it first" + ); + + self.node_mut(child).parent = Some(parent); + + if let Some(last) = self.node(parent).last_child { + self.node_mut(last).next_sibling = Some(child); + self.node_mut(child).prev_sibling = Some(last); + self.node_mut(parent).last_child = Some(child); + } else { + self.node_mut(parent).first_child = Some(child); + self.node_mut(parent).last_child = Some(child); + } + } + + /// Inserts `new_child` immediately before `reference` in the sibling list. + /// + /// The new child is given the same parent as `reference` and is linked as + /// its previous sibling. + /// + /// # Panics + /// + /// Panics if `reference` has no parent or if `new_child` already has a + /// parent (detach it first). + #[allow(clippy::expect_used)] + pub fn insert_before(&mut self, reference: NodeId, new_child: NodeId) { + debug_assert!( + self.node(new_child).parent.is_none(), + "new_child already has a parent; detach it first" + ); + + let parent = self + .node(reference) + .parent + .expect("reference has no parent"); + self.node_mut(new_child).parent = Some(parent); + + if let Some(prev) = self.node(reference).prev_sibling { + self.node_mut(prev).next_sibling = Some(new_child); + self.node_mut(new_child).prev_sibling = Some(prev); + } else { + self.node_mut(parent).first_child = Some(new_child); + } + + self.node_mut(new_child).next_sibling = Some(reference); + self.node_mut(reference).prev_sibling = Some(new_child); + } + + /// Prepends a child node as the first child of a parent. + /// + /// If the parent already has children, the new child is inserted before + /// the current first child. Otherwise, it becomes the only child. + pub fn prepend_child(&mut self, parent: NodeId, child: NodeId) { + if let Some(first) = self.first_child(parent) { + self.insert_before(first, child); + } else { + self.append_child(parent, child); + } + } + + /// Removes a node from the tree by detaching it from its parent. + /// + /// The node and its subtree remain allocated in the arena but become + /// unreachable through tree navigation. This is an alias for + /// [`detach`](Self::detach). + pub fn remove_node(&mut self, id: NodeId) { + self.detach(id); + } + + /// Detaches a node from its parent without freeing it from the arena. + /// + /// Updates sibling and parent links so the node is no longer reachable + /// through tree traversal. The node's own children are left intact, so the + /// detached subtree remains internally connected. If the node has no + /// parent, this is a no-op. + pub fn detach(&mut self, id: NodeId) { + let Some(parent) = self.node(id).parent else { + return; + }; + + let prev = self.node(id).prev_sibling; + let next = self.node(id).next_sibling; + + match prev { + Some(p) => self.node_mut(p).next_sibling = next, + None => self.node_mut(parent).first_child = next, + } + + match next { + Some(n) => self.node_mut(n).prev_sibling = prev, + None => self.node_mut(parent).last_child = prev, + } + + self.node_mut(id).parent = None; + self.node_mut(id).prev_sibling = None; + self.node_mut(id).next_sibling = None; + } + + /// Deep-copies a node and all its descendants within this document. + /// + /// The cloned subtree is detached (has no parent). If `deep` is `false`, + /// only the node itself is cloned without its children. + /// + /// # Examples + /// + /// ``` + /// use xmloxide::Document; + /// + /// let mut doc = Document::parse_str("<root><child>Hello</child></root>").unwrap(); + /// let root = doc.root_element().unwrap(); + /// let child = doc.first_child(root).unwrap(); + /// let cloned = doc.clone_node(child, true); + /// doc.append_child(root, cloned); + /// ``` + pub fn clone_node(&mut self, id: NodeId, deep: bool) -> NodeId { + let kind = self.node(id).kind.clone(); + let new_id = self.create_node(kind); + if deep { + // Clone children recursively + let children: Vec<NodeId> = self.children(id).collect(); + for child_id in children { + let cloned_child = self.clone_node(child_id, true); + self.append_child(new_id, cloned_child); + } + } + new_id + } + + /// Sets the text content of a text, CDATA, or comment node. + /// + /// For element nodes, this removes all children and replaces them with + /// a single text node containing the given content. + /// + /// Returns `true` if the content was set, `false` if the node type does + /// not support text content (e.g., the document root). + /// + /// # Examples + /// + /// ``` + /// use xmloxide::Document; + /// + /// let mut doc = Document::parse_str("<root>old</root>").unwrap(); + /// let root = doc.root_element().unwrap(); + /// let text_node = doc.first_child(root).unwrap(); + /// assert!(doc.set_text_content(text_node, "new")); + /// assert_eq!(doc.text_content(root), "new"); + /// ``` + pub fn set_text_content(&mut self, id: NodeId, content: &str) -> bool { + match &self.node(id).kind { + NodeKind::Text { .. } | NodeKind::CData { .. } | NodeKind::Comment { .. } => { + match &mut self.node_mut(id).kind { + NodeKind::Text { + content: ref mut c, .. + } + | NodeKind::CData { + content: ref mut c, .. + } + | NodeKind::Comment { + content: ref mut c, .. + } => { + *c = content.to_string(); + } + _ => unreachable!(), + } + true + } + NodeKind::Element { .. } => { + // Remove all children + let children: Vec<NodeId> = self.children(id).collect(); + for child in children { + self.remove_node(child); + } + // Add a single text node + let text = self.create_node(NodeKind::Text { + content: content.to_string(), + }); + self.append_child(id, text); + true + } + NodeKind::ProcessingInstruction { .. } => { + if let NodeKind::ProcessingInstruction { data, .. } = &mut self.node_mut(id).kind { + *data = Some(content.to_string()); + } + true + } + NodeKind::Document | NodeKind::DocumentType { .. } | NodeKind::EntityRef { .. } => { + false + } + } + } + + /// Creates a new element node (detached) and returns its `NodeId`. + /// + /// Use [`append_child`](Self::append_child), [`prepend_child`](Self::prepend_child), + /// or [`insert_before`](Self::insert_before) to attach it. + pub fn create_element(&mut self, name: &str) -> NodeId { + self.create_node(NodeKind::Element { + name: name.to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }) + } + + /// Creates a new text node (detached) and returns its `NodeId`. + pub fn create_text(&mut self, content: &str) -> NodeId { + self.create_node(NodeKind::Text { + content: content.to_string(), + }) + } + + /// Creates a new comment node (detached) and returns its `NodeId`. + pub fn create_comment(&mut self, content: &str) -> NodeId { + self.create_node(NodeKind::Comment { + content: content.to_string(), + }) + } + + /// Creates a new processing instruction node (detached) and returns its `NodeId`. + pub fn create_processing_instruction(&mut self, target: &str, data: Option<&str>) -> NodeId { + self.create_node(NodeKind::ProcessingInstruction { + target: target.to_string(), + data: data.map(ToString::to_string), + }) + } + + /// Inserts `new_child` immediately after `reference` in the sibling list. + /// + /// If `reference` is the last child, this is equivalent to appending to the parent. + /// + /// # Panics + /// + /// Panics if `reference` has no parent or if `new_child` already has a parent. + #[allow(clippy::expect_used)] + pub fn insert_after(&mut self, reference: NodeId, new_child: NodeId) { + debug_assert!( + self.node(new_child).parent.is_none(), + "new_child already has a parent; detach it first" + ); + + let parent = self + .node(reference) + .parent + .expect("reference has no parent"); + self.node_mut(new_child).parent = Some(parent); + + if let Some(next) = self.node(reference).next_sibling { + self.node_mut(next).prev_sibling = Some(new_child); + self.node_mut(new_child).next_sibling = Some(next); + } else { + self.node_mut(parent).last_child = Some(new_child); + } + + self.node_mut(new_child).prev_sibling = Some(reference); + self.node_mut(reference).next_sibling = Some(new_child); + } + + /// Replaces `old_node` with `new_node` in the tree. + /// + /// The old node is detached and the new node takes its place in the + /// sibling list. Returns the id of the old (now detached) node. + /// + /// # Panics + /// + /// Panics if `old_node` has no parent or if `new_node` already has a parent. + pub fn replace_node(&mut self, old_node: NodeId, new_node: NodeId) -> NodeId { + self.insert_before(old_node, new_node); + self.detach(old_node); + old_node + } + + /// Sets an attribute on an element node. If the attribute already exists, + /// its value is updated. Returns `true` on success, `false` if the node + /// is not an element. + pub fn set_attribute(&mut self, id: NodeId, name: &str, value: &str) -> bool { + if let NodeKind::Element { attributes, .. } = &mut self.node_mut(id).kind { + if let Some(attr) = attributes.iter_mut().find(|a| a.name == name) { + attr.value = value.to_string(); + attr.raw_value = None; + } else { + attributes.push(Attribute { + name: name.to_string(), + value: value.to_string(), + prefix: None, + namespace: None, + raw_value: None, + }); + } + true + } else { + false + } + } + + /// Removes an attribute by name from an element node. + /// + /// Returns `true` if the attribute was found and removed, `false` if the + /// attribute was not present or the node is not an element. + pub fn remove_attribute(&mut self, id: NodeId, name: &str) -> bool { + if let NodeKind::Element { attributes, .. } = &mut self.node_mut(id).kind { + let len = attributes.len(); + attributes.retain(|a| a.name != name); + attributes.len() < len + } else { + false + } + } + + /// Renames an element node. Returns `true` on success, `false` if the + /// node is not an element. + pub fn rename_element(&mut self, id: NodeId, new_name: &str) -> bool { + if let NodeKind::Element { name, .. } = &mut self.node_mut(id).kind { + *name = new_name.to_string(); + true + } else { + false + } + } + + /// Returns the total number of nodes in the document. + /// + /// This includes all node types (elements, text, comments, etc.) but + /// excludes the internal arena placeholder. Detached nodes that have not + /// been garbage-collected are still counted. + #[must_use] + pub fn node_count(&self) -> usize { + self.nodes.len() - 1 // subtract placeholder at index 0 + } +} + +impl Default for Document { + fn default() -> Self { + Self::new() + } +} + +// --- Iterators --- + +/// Iterator over the direct children of a node. +/// +/// Created by [`Document::children`]. Yields each child's `NodeId` in +/// document order by following `next_sibling` links. +pub struct Children<'a> { + doc: &'a Document, + next: Option<NodeId>, +} + +impl Iterator for Children<'_> { + type Item = NodeId; + + #[inline] + fn next(&mut self) -> Option<Self::Item> { + let current = self.next?; + self.next = self.doc.nodes[current.as_index()].next_sibling; + Some(current) + } +} + +/// Iterator over a node and its ancestors, walking up toward the document root. +/// +/// Created by [`Document::ancestors`]. The first item yielded is the starting +/// node itself, followed by its parent, grandparent, etc. +pub struct Ancestors<'a> { + doc: &'a Document, + next: Option<NodeId>, +} + +impl Iterator for Ancestors<'_> { + type Item = NodeId; + + #[inline] + fn next(&mut self) -> Option<Self::Item> { + let current = self.next?; + self.next = self.doc.node(current).parent; + Some(current) + } +} + +/// Depth-first (pre-order) iterator over all descendants of a node. +/// +/// Created by [`Document::descendants`]. Yields every node in the subtree +/// below the starting node, but does *not* yield the starting node itself. +pub struct Descendants<'a> { + doc: &'a Document, + root: NodeId, + next: Option<NodeId>, +} + +impl Iterator for Descendants<'_> { + type Item = NodeId; + + #[inline] + fn next(&mut self) -> Option<Self::Item> { + let current = self.next?; + let nodes = &self.doc.nodes; + + // Try to go deeper first + let node = &nodes[current.as_index()]; + if let Some(child) = node.first_child { + self.next = Some(child); + return Some(current); + } + + // Try next sibling + if let Some(sibling) = node.next_sibling { + self.next = Some(sibling); + return Some(current); + } + + // Walk up to find an ancestor with a next sibling + let mut ancestor = node.parent; + while let Some(anc) = ancestor { + if anc == self.root { + self.next = None; + return Some(current); + } + let anc_node = &nodes[anc.as_index()]; + if let Some(sibling) = anc_node.next_sibling { + self.next = Some(sibling); + return Some(current); + } + ancestor = anc_node.parent; + } + + self.next = None; + Some(current) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn test_new_document_has_root() { + let doc = Document::new(); + assert!(matches!(doc.node(doc.root()).kind, NodeKind::Document)); + assert_eq!(doc.node_count(), 1); // just the root + } + + #[test] + fn test_create_and_append_element() { + let mut doc = Document::new(); + let root = doc.root(); + let elem = doc.create_node(NodeKind::Element { + name: "div".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + doc.append_child(root, elem); + + assert_eq!(doc.first_child(root), Some(elem)); + assert_eq!(doc.last_child(root), Some(elem)); + assert_eq!(doc.parent(elem), Some(root)); + assert_eq!(doc.node_name(elem), Some("div")); + } + + #[test] + fn test_append_multiple_children() { + let mut doc = Document::new(); + let root = doc.root(); + + let a = doc.create_node(NodeKind::Text { + content: "A".to_string(), + }); + let b = doc.create_node(NodeKind::Text { + content: "B".to_string(), + }); + let c = doc.create_node(NodeKind::Text { + content: "C".to_string(), + }); + + doc.append_child(root, a); + doc.append_child(root, b); + doc.append_child(root, c); + + assert_eq!(doc.first_child(root), Some(a)); + assert_eq!(doc.last_child(root), Some(c)); + assert_eq!(doc.next_sibling(a), Some(b)); + assert_eq!(doc.next_sibling(b), Some(c)); + assert_eq!(doc.next_sibling(c), None); + assert_eq!(doc.prev_sibling(c), Some(b)); + assert_eq!(doc.prev_sibling(b), Some(a)); + assert_eq!(doc.prev_sibling(a), None); + } + + #[test] + fn test_children_iterator() { + let mut doc = Document::new(); + let root = doc.root(); + + let a = doc.create_node(NodeKind::Text { + content: "A".to_string(), + }); + let b = doc.create_node(NodeKind::Text { + content: "B".to_string(), + }); + let c = doc.create_node(NodeKind::Text { + content: "C".to_string(), + }); + + doc.append_child(root, a); + doc.append_child(root, b); + doc.append_child(root, c); + + let children: Vec<NodeId> = doc.children(root).collect(); + assert_eq!(children, vec![a, b, c]); + } + + #[test] + fn test_children_iterator_empty() { + let doc = Document::new(); + let children: Vec<NodeId> = doc.children(doc.root()).collect(); + assert!(children.is_empty()); + } + + #[test] + fn test_insert_before() { + let mut doc = Document::new(); + let root = doc.root(); + + let a = doc.create_node(NodeKind::Text { + content: "A".to_string(), + }); + let c = doc.create_node(NodeKind::Text { + content: "C".to_string(), + }); + doc.append_child(root, a); + doc.append_child(root, c); + + let b = doc.create_node(NodeKind::Text { + content: "B".to_string(), + }); + doc.insert_before(c, b); + + let children: Vec<NodeId> = doc.children(root).collect(); + assert_eq!(children, vec![a, b, c]); + assert_eq!(doc.parent(b), Some(root)); + } + + #[test] + fn test_insert_before_first_child() { + let mut doc = Document::new(); + let root = doc.root(); + + let b = doc.create_node(NodeKind::Text { + content: "B".to_string(), + }); + doc.append_child(root, b); + + let a = doc.create_node(NodeKind::Text { + content: "A".to_string(), + }); + doc.insert_before(b, a); + + assert_eq!(doc.first_child(root), Some(a)); + assert_eq!(doc.next_sibling(a), Some(b)); + } + + #[test] + fn test_detach() { + let mut doc = Document::new(); + let root = doc.root(); + + let a = doc.create_node(NodeKind::Text { + content: "A".to_string(), + }); + let b = doc.create_node(NodeKind::Text { + content: "B".to_string(), + }); + let c = doc.create_node(NodeKind::Text { + content: "C".to_string(), + }); + + doc.append_child(root, a); + doc.append_child(root, b); + doc.append_child(root, c); + + doc.detach(b); + + let children: Vec<NodeId> = doc.children(root).collect(); + assert_eq!(children, vec![a, c]); + assert_eq!(doc.parent(b), None); + assert_eq!(doc.next_sibling(a), Some(c)); + assert_eq!(doc.prev_sibling(c), Some(a)); + } + + #[test] + fn test_detach_first_child() { + let mut doc = Document::new(); + let root = doc.root(); + + let a = doc.create_node(NodeKind::Text { + content: "A".to_string(), + }); + let b = doc.create_node(NodeKind::Text { + content: "B".to_string(), + }); + doc.append_child(root, a); + doc.append_child(root, b); + + doc.detach(a); + assert_eq!(doc.first_child(root), Some(b)); + assert_eq!(doc.prev_sibling(b), None); + } + + #[test] + fn test_detach_last_child() { + let mut doc = Document::new(); + let root = doc.root(); + + let a = doc.create_node(NodeKind::Text { + content: "A".to_string(), + }); + let b = doc.create_node(NodeKind::Text { + content: "B".to_string(), + }); + doc.append_child(root, a); + doc.append_child(root, b); + + doc.detach(b); + assert_eq!(doc.last_child(root), Some(a)); + assert_eq!(doc.next_sibling(a), None); + } + + #[test] + fn test_detach_only_child() { + let mut doc = Document::new(); + let root = doc.root(); + + let a = doc.create_node(NodeKind::Text { + content: "A".to_string(), + }); + doc.append_child(root, a); + doc.detach(a); + + assert_eq!(doc.first_child(root), None); + assert_eq!(doc.last_child(root), None); + } + + #[test] + fn test_ancestors_iterator() { + let mut doc = Document::new(); + let root = doc.root(); + + let parent = doc.create_node(NodeKind::Element { + name: "parent".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + let child = doc.create_node(NodeKind::Element { + name: "child".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + + doc.append_child(root, parent); + doc.append_child(parent, child); + + let ancestors: Vec<NodeId> = doc.ancestors(child).collect(); + assert_eq!(ancestors, vec![child, parent, root]); + } + + #[test] + fn test_descendants_iterator() { + let mut doc = Document::new(); + let root = doc.root(); + + let p = doc.create_node(NodeKind::Element { + name: "p".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + let a = doc.create_node(NodeKind::Text { + content: "hello ".to_string(), + }); + let b = doc.create_node(NodeKind::Element { + name: "b".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + let b_text = doc.create_node(NodeKind::Text { + content: "world".to_string(), + }); + + doc.append_child(root, p); + doc.append_child(p, a); + doc.append_child(p, b); + doc.append_child(b, b_text); + + let desc: Vec<NodeId> = doc.descendants(root).collect(); + assert_eq!(desc, vec![p, a, b, b_text]); + } + + #[test] + fn test_text_content() { + let mut doc = Document::new(); + let root = doc.root(); + + let p = doc.create_node(NodeKind::Element { + name: "p".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + let text1 = doc.create_node(NodeKind::Text { + content: "hello ".to_string(), + }); + let bold = doc.create_node(NodeKind::Element { + name: "b".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + let text2 = doc.create_node(NodeKind::Text { + content: "world".to_string(), + }); + + doc.append_child(root, p); + doc.append_child(p, text1); + doc.append_child(p, bold); + doc.append_child(bold, text2); + + assert_eq!(doc.text_content(p), "hello world"); + } + + #[test] + fn test_text_content_entity_ref_children_take_priority() { + // When an EntityRef node carries parsed replacement children + // (XML 1.0 §4.4), text_content reads them, not the stored value. + let mut doc = Document::new(); + let root = doc.root(); + let entity = doc.create_node(NodeKind::EntityRef { + name: "e".to_string(), + value: Some("raw value".to_string()), + }); + let text = doc.create_node(NodeKind::Text { + content: "expanded".to_string(), + }); + doc.append_child(root, entity); + doc.append_child(entity, text); + assert_eq!(doc.text_content(root), "expanded"); + } + + #[test] + fn test_text_content_entity_ref_value_fallback() { + // A childless EntityRef (e.g. built programmatically) falls back to + // its stored replacement value. + let mut doc = Document::new(); + let root = doc.root(); + let entity = doc.create_node(NodeKind::EntityRef { + name: "e".to_string(), + value: Some("fallback".to_string()), + }); + doc.append_child(root, entity); + assert_eq!(doc.text_content(root), "fallback"); + } + + #[test] + fn test_attributes() { + let mut doc = Document::new(); + let root = doc.root(); + + let elem = doc.create_node(NodeKind::Element { + name: "div".to_string(), + prefix: None, + namespace: None, + attributes: vec![ + Attribute { + name: "id".to_string(), + value: "main".to_string(), + prefix: None, + namespace: None, + raw_value: None, + }, + Attribute { + name: "class".to_string(), + value: "container".to_string(), + prefix: None, + namespace: None, + raw_value: None, + }, + ], + }); + doc.append_child(root, elem); + + assert_eq!(doc.attribute(elem, "id"), Some("main")); + assert_eq!(doc.attribute(elem, "class"), Some("container")); + assert_eq!(doc.attribute(elem, "style"), None); + assert_eq!(doc.attributes(elem).len(), 2); + } + + #[test] + fn test_root_element() { + let mut doc = Document::new(); + let root = doc.root(); + + // No element children yet + assert_eq!(doc.root_element(), None); + + let elem = doc.create_node(NodeKind::Element { + name: "root".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + doc.append_child(root, elem); + + assert_eq!(doc.root_element(), Some(elem)); + } + + #[test] + fn test_node_text() { + let mut doc = Document::new(); + + let text = doc.create_node(NodeKind::Text { + content: "hello".to_string(), + }); + assert_eq!(doc.node_text(text), Some("hello")); + + let comment = doc.create_node(NodeKind::Comment { + content: "a comment".to_string(), + }); + assert_eq!(doc.node_text(comment), Some("a comment")); + + let cdata = doc.create_node(NodeKind::CData { + content: "cdata content".to_string(), + }); + assert_eq!(doc.node_text(cdata), Some("cdata content")); + + let elem = doc.create_node(NodeKind::Element { + name: "div".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + assert_eq!(doc.node_text(elem), None); + } + + #[test] + fn test_element_by_id_none() { + let doc = Document::new(); + assert_eq!(doc.element_by_id("nonexistent"), None); + } + + #[test] + fn test_set_id_and_lookup() { + let mut doc = Document::new(); + let root = doc.root(); + let elem = doc.create_node(NodeKind::Element { + name: "item".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + doc.append_child(root, elem); + doc.set_id("a", elem); + assert_eq!(doc.element_by_id("a"), Some(elem)); + assert_eq!(doc.element_by_id("b"), None); + } + + #[test] + fn test_remove_node_middle_child() { + let mut doc = Document::new(); + let root = doc.root(); + + let a = doc.create_node(NodeKind::Text { + content: "A".to_string(), + }); + let b = doc.create_node(NodeKind::Text { + content: "B".to_string(), + }); + let c = doc.create_node(NodeKind::Text { + content: "C".to_string(), + }); + + doc.append_child(root, a); + doc.append_child(root, b); + doc.append_child(root, c); + + doc.remove_node(b); + + let children: Vec<NodeId> = doc.children(root).collect(); + assert_eq!(children, vec![a, c]); + assert_eq!(doc.parent(b), None); + assert_eq!(doc.next_sibling(b), None); + assert_eq!(doc.prev_sibling(b), None); + assert_eq!(doc.next_sibling(a), Some(c)); + assert_eq!(doc.prev_sibling(c), Some(a)); + } + + #[test] + fn test_remove_node_only_child() { + let mut doc = Document::new(); + let root = doc.root(); + + let a = doc.create_node(NodeKind::Element { + name: "only".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + doc.append_child(root, a); + + doc.remove_node(a); + + assert_eq!(doc.first_child(root), None); + assert_eq!(doc.last_child(root), None); + assert_eq!(doc.parent(a), None); + } + + #[test] + fn test_remove_node_first_child() { + let mut doc = Document::new(); + let root = doc.root(); + + let a = doc.create_node(NodeKind::Text { + content: "A".to_string(), + }); + let b = doc.create_node(NodeKind::Text { + content: "B".to_string(), + }); + doc.append_child(root, a); + doc.append_child(root, b); + + doc.remove_node(a); + + assert_eq!(doc.first_child(root), Some(b)); + assert_eq!(doc.prev_sibling(b), None); + assert_eq!(doc.parent(a), None); + } + + #[test] + fn test_remove_node_last_child() { + let mut doc = Document::new(); + let root = doc.root(); + + let a = doc.create_node(NodeKind::Text { + content: "A".to_string(), + }); + let b = doc.create_node(NodeKind::Text { + content: "B".to_string(), + }); + doc.append_child(root, a); + doc.append_child(root, b); + + doc.remove_node(b); + + assert_eq!(doc.last_child(root), Some(a)); + assert_eq!(doc.next_sibling(a), None); + assert_eq!(doc.parent(b), None); + } + + #[test] + fn test_remove_node_no_parent_is_noop() { + let mut doc = Document::new(); + let orphan = doc.create_node(NodeKind::Text { + content: "orphan".to_string(), + }); + + // Removing a node with no parent should not panic + doc.remove_node(orphan); + + assert_eq!(doc.parent(orphan), None); + } + + #[test] + fn test_prepend_child_to_empty_parent() { + let mut doc = Document::new(); + let root = doc.root(); + + let a = doc.create_node(NodeKind::Element { + name: "first".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + + doc.prepend_child(root, a); + + assert_eq!(doc.first_child(root), Some(a)); + assert_eq!(doc.last_child(root), Some(a)); + assert_eq!(doc.parent(a), Some(root)); + } + + #[test] + fn test_prepend_child_before_existing() { + let mut doc = Document::new(); + let root = doc.root(); + + let b = doc.create_node(NodeKind::Text { + content: "B".to_string(), + }); + let c = doc.create_node(NodeKind::Text { + content: "C".to_string(), + }); + doc.append_child(root, b); + doc.append_child(root, c); + + let a = doc.create_node(NodeKind::Text { + content: "A".to_string(), + }); + doc.prepend_child(root, a); + + let children: Vec<NodeId> = doc.children(root).collect(); + assert_eq!(children, vec![a, b, c]); + assert_eq!(doc.first_child(root), Some(a)); + assert_eq!(doc.last_child(root), Some(c)); + assert_eq!(doc.parent(a), Some(root)); + assert_eq!(doc.next_sibling(a), Some(b)); + assert_eq!(doc.prev_sibling(b), Some(a)); + } + + #[test] + fn test_prepend_child_multiple_times() { + let mut doc = Document::new(); + let root = doc.root(); + + let c = doc.create_node(NodeKind::Text { + content: "C".to_string(), + }); + let b = doc.create_node(NodeKind::Text { + content: "B".to_string(), + }); + let a = doc.create_node(NodeKind::Text { + content: "A".to_string(), + }); + + doc.prepend_child(root, c); + doc.prepend_child(root, b); + doc.prepend_child(root, a); + + let children: Vec<NodeId> = doc.children(root).collect(); + assert_eq!(children, vec![a, b, c]); + } + + #[test] + fn test_node_namespace_with_namespace() { + let mut doc = Document::new(); + let elem = doc.create_node(NodeKind::Element { + name: "rect".to_string(), + prefix: Some("svg".to_string()), + namespace: Some("http://www.w3.org/2000/svg".to_string()), + attributes: vec![], + }); + + assert_eq!(doc.node_namespace(elem), Some("http://www.w3.org/2000/svg")); + } + + #[test] + fn test_node_namespace_without_namespace() { + let mut doc = Document::new(); + let elem = doc.create_node(NodeKind::Element { + name: "div".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + + assert_eq!(doc.node_namespace(elem), None); + } + + #[test] + fn test_node_namespace_non_element() { + let mut doc = Document::new(); + let text = doc.create_node(NodeKind::Text { + content: "hello".to_string(), + }); + let comment = doc.create_node(NodeKind::Comment { + content: "a comment".to_string(), + }); + + assert_eq!(doc.node_namespace(text), None); + assert_eq!(doc.node_namespace(comment), None); + } + + #[test] + fn test_first_child_of_leaf_node() { + let mut doc = Document::new(); + let root = doc.root(); + + let leaf = doc.create_node(NodeKind::Text { + content: "leaf".to_string(), + }); + doc.append_child(root, leaf); + + assert_eq!(doc.first_child(leaf), None); + } + + #[test] + fn test_last_child_of_leaf_node() { + let mut doc = Document::new(); + let root = doc.root(); + + let leaf = doc.create_node(NodeKind::Text { + content: "leaf".to_string(), + }); + doc.append_child(root, leaf); + + assert_eq!(doc.last_child(leaf), None); + } + + #[test] + fn test_first_child_last_child_single_child() { + let mut doc = Document::new(); + let root = doc.root(); + + let only = doc.create_node(NodeKind::Element { + name: "only".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + doc.append_child(root, only); + + assert_eq!(doc.first_child(root), Some(only)); + assert_eq!(doc.last_child(root), Some(only)); + } + + #[test] + fn test_first_child_last_child_multiple_children() { + let mut doc = Document::new(); + let root = doc.root(); + + let first = doc.create_node(NodeKind::Text { + content: "first".to_string(), + }); + let middle = doc.create_node(NodeKind::Text { + content: "middle".to_string(), + }); + let last = doc.create_node(NodeKind::Text { + content: "last".to_string(), + }); + + doc.append_child(root, first); + doc.append_child(root, middle); + doc.append_child(root, last); + + assert_eq!(doc.first_child(root), Some(first)); + assert_eq!(doc.last_child(root), Some(last)); + assert_ne!(doc.first_child(root), doc.last_child(root)); + } + + #[test] + fn test_next_sibling_last_has_none() { + let mut doc = Document::new(); + let root = doc.root(); + + let a = doc.create_node(NodeKind::Text { + content: "A".to_string(), + }); + let b = doc.create_node(NodeKind::Text { + content: "B".to_string(), + }); + + doc.append_child(root, a); + doc.append_child(root, b); + + assert_eq!(doc.next_sibling(a), Some(b)); + assert_eq!(doc.next_sibling(b), None); + } + + #[test] + fn test_prev_sibling_first_has_none() { + let mut doc = Document::new(); + let root = doc.root(); + + let a = doc.create_node(NodeKind::Text { + content: "A".to_string(), + }); + let b = doc.create_node(NodeKind::Text { + content: "B".to_string(), + }); + + doc.append_child(root, a); + doc.append_child(root, b); + + assert_eq!(doc.prev_sibling(a), None); + assert_eq!(doc.prev_sibling(b), Some(a)); + } + + #[test] + fn test_next_prev_sibling_chain() { + let mut doc = Document::new(); + let root = doc.root(); + + let a = doc.create_node(NodeKind::Text { + content: "A".to_string(), + }); + let b = doc.create_node(NodeKind::Text { + content: "B".to_string(), + }); + let c = doc.create_node(NodeKind::Text { + content: "C".to_string(), + }); + + doc.append_child(root, a); + doc.append_child(root, b); + doc.append_child(root, c); + + // Forward traversal + assert_eq!(doc.next_sibling(a), Some(b)); + assert_eq!(doc.next_sibling(b), Some(c)); + assert_eq!(doc.next_sibling(c), None); + + // Backward traversal + assert_eq!(doc.prev_sibling(c), Some(b)); + assert_eq!(doc.prev_sibling(b), Some(a)); + assert_eq!(doc.prev_sibling(a), None); + } + + #[test] + fn test_parse_str_simple_element() { + let Ok(doc) = Document::parse_str("<root/>") else { + panic!("failed to parse simple element"); + }; + let Some(root) = doc.root_element() else { + panic!("parsed document has no root element"); + }; + assert_eq!(doc.node_name(root), Some("root")); + } + + #[test] + fn test_parse_str_nested_elements() { + let Ok(doc) = Document::parse_str("<parent><child/></parent>") else { + panic!("failed to parse nested elements"); + }; + let Some(root) = doc.root_element() else { + panic!("parsed document has no root element"); + }; + assert_eq!(doc.node_name(root), Some("parent")); + + let Some(child) = doc.first_child(root) else { + panic!("root element has no children"); + }; + assert_eq!(doc.node_name(child), Some("child")); + } + + #[test] + fn test_parse_str_with_text_content() { + let Ok(doc) = Document::parse_str("<msg>hello</msg>") else { + panic!("failed to parse element with text"); + }; + let Some(root) = doc.root_element() else { + panic!("parsed document has no root element"); + }; + assert_eq!(doc.text_content(root), "hello"); + } + + #[test] + fn test_parse_str_with_attributes() { + let Ok(doc) = Document::parse_str(r#"<div id="main" class="x"/>"#) else { + panic!("failed to parse element with attributes"); + }; + let Some(root) = doc.root_element() else { + panic!("parsed document has no root element"); + }; + assert_eq!(doc.attribute(root, "id"), Some("main")); + assert_eq!(doc.attribute(root, "class"), Some("x")); + } + + #[test] + fn test_parse_bytes_utf8() { + let input = b"<root>hello</root>"; + let Ok(doc) = Document::parse_bytes(input) else { + panic!("failed to parse bytes"); + }; + let Some(root) = doc.root_element() else { + panic!("parsed document has no root element"); + }; + assert_eq!(doc.node_name(root), Some("root")); + assert_eq!(doc.text_content(root), "hello"); + } + + #[test] + fn test_parse_bytes_with_xml_declaration() { + let input = b"<?xml version=\"1.0\" encoding=\"UTF-8\"?><data/>"; + let Ok(doc) = Document::parse_bytes(input) else { + panic!("failed to parse bytes with XML declaration"); + }; + let Some(root) = doc.root_element() else { + panic!("parsed document has no root element"); + }; + assert_eq!(doc.node_name(root), Some("data")); + } + + #[test] + fn test_parse_bytes_with_bom() { + // UTF-8 BOM followed by XML + let mut input = vec![0xEF, 0xBB, 0xBF]; + input.extend_from_slice(b"<root/>"); + let Ok(doc) = Document::parse_bytes(&input) else { + panic!("failed to parse bytes with BOM"); + }; + let Some(root) = doc.root_element() else { + panic!("parsed document has no root element"); + }; + assert_eq!(doc.node_name(root), Some("root")); + } + + #[test] + fn test_node_count_empty_document() { + let doc = Document::new(); + // A new document has exactly 1 node: the document root node + assert_eq!(doc.node_count(), 1); + } + + #[test] + fn test_node_count_after_creating_nodes() { + let mut doc = Document::new(); + let root = doc.root(); + + let a = doc.create_node(NodeKind::Element { + name: "a".to_string(), + prefix: None, + namespace: None, + attributes: vec![], + }); + assert_eq!(doc.node_count(), 2); + + let b = doc.create_node(NodeKind::Text { + content: "text".to_string(), + }); + assert_eq!(doc.node_count(), 3); + + doc.append_child(root, a); + doc.append_child(a, b); + + // Appending does not change the count — nodes already exist in arena + assert_eq!(doc.node_count(), 3); + } + + #[test] + fn test_node_count_after_remove() { + let mut doc = Document::new(); + let root = doc.root(); + + let a = doc.create_node(NodeKind::Text { + content: "A".to_string(), + }); + doc.append_child(root, a); + assert_eq!(doc.node_count(), 2); + + // Removing a node does not free it from the arena + doc.remove_node(a); + assert_eq!(doc.node_count(), 2); + } + + #[test] + fn test_clone_node_shallow() { + let mut doc = Document::parse_str("<root><child>Hello</child></root>").unwrap(); + let root = doc.root_element().unwrap(); + let child = doc.first_child(root).unwrap(); + + let cloned = doc.clone_node(child, false); + assert_eq!(doc.node_name(cloned), Some("child")); + // Shallow clone has no children + assert!(doc.first_child(cloned).is_none()); + // Clone is detached + assert!(doc.parent(cloned).is_none()); + } + + #[test] + fn test_clone_node_deep() { + let mut doc = + Document::parse_str("<root><parent><child>Hello</child></parent></root>").unwrap(); + let root = doc.root_element().unwrap(); + let parent_elem = doc.first_child(root).unwrap(); + + let cloned = doc.clone_node(parent_elem, true); + assert_eq!(doc.node_name(cloned), Some("parent")); + // Deep clone has children + let cloned_child = doc.first_child(cloned).unwrap(); + assert_eq!(doc.node_name(cloned_child), Some("child")); + let cloned_text = doc.first_child(cloned_child).unwrap(); + assert_eq!(doc.node_text(cloned_text), Some("Hello")); + // Clone is detached + assert!(doc.parent(cloned).is_none()); + // Original is unchanged + assert!(doc.first_child(parent_elem).is_some()); + } + + #[test] + fn test_clone_node_and_append() { + let mut doc = Document::parse_str("<root><item>A</item></root>").unwrap(); + let root = doc.root_element().unwrap(); + let item = doc.first_child(root).unwrap(); + + let cloned = doc.clone_node(item, true); + doc.append_child(root, cloned); + + let children: Vec<_> = doc.children(root).collect(); + assert_eq!(children.len(), 2); + assert_eq!(doc.text_content(children[0]), "A"); + assert_eq!(doc.text_content(children[1]), "A"); + } + + #[test] + fn test_parse_file_nonexistent() { + let result = Document::parse_file("/nonexistent/path.xml"); + assert!(result.is_err()); + assert!(result.unwrap_err().message.contains("failed to read file")); + } + + #[test] + fn test_parse_file_valid() { + use std::io::Write; + let dir = std::env::temp_dir().join("xmloxide_test_parse_file"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("test.xml"); + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(b"<root>Hello</root>").unwrap(); + drop(f); + + let doc = Document::parse_file(&path).unwrap(); + let root = doc.root_element().unwrap(); + assert_eq!(doc.node_name(root), Some("root")); + assert_eq!(doc.text_content(root), "Hello"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn test_create_element_convenience() { + let mut doc = Document::new(); + let elem = doc.create_element("div"); + assert_eq!(doc.node_name(elem), Some("div")); + assert!(matches!(doc.node(elem).kind, NodeKind::Element { .. })); + } + + #[test] + fn test_create_text_convenience() { + let mut doc = Document::new(); + let t = doc.create_text("hello"); + assert_eq!(doc.node_text(t), Some("hello")); + } + + #[test] + fn test_create_comment_convenience() { + let mut doc = Document::new(); + let c = doc.create_comment("a comment"); + assert_eq!(doc.node_text(c), Some("a comment")); + assert!(matches!(doc.node(c).kind, NodeKind::Comment { .. })); + } + + #[test] + fn test_create_processing_instruction() { + let mut doc = Document::new(); + let pi = doc.create_processing_instruction("target", Some("data")); + assert_eq!(doc.node_name(pi), Some("target")); + assert_eq!(doc.node_text(pi), Some("data")); + } + + #[test] + fn test_insert_after() { + let mut doc = Document::new(); + let root = doc.root(); + let a = doc.create_text("A"); + let b = doc.create_text("B"); + let c = doc.create_text("C"); + + doc.append_child(root, a); + doc.append_child(root, c); + doc.insert_after(a, b); + + let children: Vec<NodeId> = doc.children(root).collect(); + assert_eq!(children, vec![a, b, c]); + assert_eq!(doc.next_sibling(a), Some(b)); + assert_eq!(doc.next_sibling(b), Some(c)); + assert_eq!(doc.prev_sibling(c), Some(b)); + } + + #[test] + fn test_insert_after_last() { + let mut doc = Document::new(); + let root = doc.root(); + let a = doc.create_text("A"); + let b = doc.create_text("B"); + + doc.append_child(root, a); + doc.insert_after(a, b); + + assert_eq!(doc.last_child(root), Some(b)); + assert_eq!(doc.next_sibling(a), Some(b)); + assert_eq!(doc.prev_sibling(b), Some(a)); + } + + #[test] + fn test_replace_node() { + let mut doc = Document::new(); + let root = doc.root(); + let a = doc.create_text("A"); + let b = doc.create_text("B"); + let c = doc.create_text("C"); + let new_b = doc.create_text("NEW_B"); + + doc.append_child(root, a); + doc.append_child(root, b); + doc.append_child(root, c); + + doc.replace_node(b, new_b); + + let children: Vec<NodeId> = doc.children(root).collect(); + assert_eq!(children, vec![a, new_b, c]); + assert!(doc.parent(b).is_none()); // old node detached + } + + #[test] + fn test_set_attribute() { + let mut doc = Document::new(); + let elem = doc.create_element("div"); + assert!(doc.set_attribute(elem, "class", "foo")); + assert_eq!(doc.attribute(elem, "class"), Some("foo")); + + // Update existing + assert!(doc.set_attribute(elem, "class", "bar")); + assert_eq!(doc.attribute(elem, "class"), Some("bar")); + } + + #[test] + fn test_set_attribute_on_non_element() { + let mut doc = Document::new(); + let text = doc.create_text("hello"); + assert!(!doc.set_attribute(text, "class", "foo")); + } + + #[test] + fn test_remove_attribute() { + let mut doc = Document::new(); + let elem = doc.create_element("div"); + doc.set_attribute(elem, "class", "foo"); + doc.set_attribute(elem, "id", "bar"); + + assert!(doc.remove_attribute(elem, "class")); + assert_eq!(doc.attribute(elem, "class"), None); + assert_eq!(doc.attribute(elem, "id"), Some("bar")); + + // Removing non-existent returns false + assert!(!doc.remove_attribute(elem, "class")); + } + + #[test] + fn test_remove_attribute_on_non_element() { + let mut doc = Document::new(); + let text = doc.create_text("hello"); + assert!(!doc.remove_attribute(text, "class")); + } + + #[test] + fn test_rename_element() { + let mut doc = Document::new(); + let elem = doc.create_element("div"); + assert!(doc.rename_element(elem, "span")); + assert_eq!(doc.node_name(elem), Some("span")); + } + + #[test] + fn test_rename_non_element() { + let mut doc = Document::new(); + let text = doc.create_text("hello"); + assert!(!doc.rename_element(text, "span")); + } +} diff --git a/browser/vendor/xmloxide/src/tree/node.rs b/browser/vendor/xmloxide/src/tree/node.rs new file mode 100644 index 000000000..e6199d813 --- /dev/null +++ b/browser/vendor/xmloxide/src/tree/node.rs @@ -0,0 +1,77 @@ +//! Node type definitions. +//! +//! The `NodeKind` enum represents all node types in an XML document tree, +//! corresponding to libxml2's `xmlElementType`. Each variant carries the +//! node-type-specific payload (e.g., element name and attributes, text content). + +use super::Attribute; + +/// The kind of an XML node and its associated data. +/// +/// This enum carries the payload for each node type. Navigation links +/// (parent, children, siblings) are stored in `NodeData`, not here. +#[derive(Debug, Clone)] +pub enum NodeKind { + /// The document node — there is exactly one per `Document`. + Document, + + /// An element node, e.g., `<div class="x">`. + Element { + /// The element's local name (or full `QName` before namespace resolution). + name: String, + /// Namespace prefix (e.g., `"svg"` in `svg:rect`), if any. + prefix: Option<String>, + /// Namespace URI after resolution, if any. + namespace: Option<String>, + /// Attributes on this element. + attributes: Vec<Attribute>, + }, + + /// A text node containing character data. + Text { + /// The text content (already decoded — character references resolved). + content: String, + }, + + /// A CDATA section, e.g., `<![CDATA[...]]>`. + CData { + /// The CDATA content (no escaping applied). + content: String, + }, + + /// A comment node, e.g., `<!-- ... -->`. + Comment { + /// The comment text (without the `<!--` and `-->` delimiters). + content: String, + }, + + /// A processing instruction, e.g., `<?target data?>`. + ProcessingInstruction { + /// The PI target (e.g., `"xml-stylesheet"`). + target: String, + /// The PI data, if any. + data: Option<String>, + }, + + /// An entity reference node (e.g., `&amp;` when not expanded). + EntityRef { + /// The entity name (without `&` and `;`). + name: String, + /// The expanded value of the entity (used for `text_content()`). + value: Option<String>, + }, + + /// A document type declaration node, e.g., `<!DOCTYPE html>`. + /// + /// See XML 1.0 §2.8: `[28]` doctypedecl + DocumentType { + /// The root element name declared in the DOCTYPE. + name: String, + /// The SYSTEM identifier (URI), if any. + system_id: Option<String>, + /// The PUBLIC identifier, if any. + public_id: Option<String>, + /// The serialized internal subset content (between `[` and `]`), if any. + internal_subset: Option<String>, + }, +} diff --git a/browser/vendor/xmloxide/src/util/dict.rs b/browser/vendor/xmloxide/src/util/dict.rs new file mode 100644 index 000000000..eb1ca9c74 --- /dev/null +++ b/browser/vendor/xmloxide/src/util/dict.rs @@ -0,0 +1,175 @@ +//! String interning dictionary. +//! +//! The `Dict` provides string interning so that element names, attribute names, +//! namespace URIs, and other frequently repeated strings are stored once and +//! compared by index rather than by value. This is critical for parser +//! performance — hot-path string comparisons use `SymbolId` equality (a single +//! `u32` compare) instead of full string comparison. +//! +//! See libxml2's `dict.c` for the reference implementation. + +use std::collections::HashMap; +use std::num::NonZeroU32; + +/// An interned string identifier. +/// +/// Two `SymbolId` values are equal if and only if they refer to the same +/// interned string within the same `Dict`. Comparing `SymbolId` is O(1). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(transparent)] +pub struct SymbolId(NonZeroU32); + +impl SymbolId { + /// Returns the raw index value. + #[must_use] + pub fn as_u32(self) -> u32 { + self.0.get() + } +} + +/// A string interning dictionary. +/// +/// Stores unique strings and returns `SymbolId` handles for O(1) equality +/// comparisons. The dictionary owns all interned strings. +/// +/// # Examples +/// +/// ```ignore +/// use xmloxide::util::dict::Dict; +/// +/// let mut dict = Dict::new(); +/// let a = dict.intern("hello"); +/// let b = dict.intern("hello"); +/// let c = dict.intern("world"); +/// +/// assert_eq!(a, b); +/// assert_ne!(a, c); +/// assert_eq!(dict.resolve(a), "hello"); +/// ``` +#[derive(Debug)] +pub struct Dict { + /// Map from string to its symbol id for O(1) lookup-or-insert. + map: HashMap<String, SymbolId>, + /// Indexed storage: symbol id → string. Index 0 is unused (`NonZeroU32`). + strings: Vec<String>, +} + +impl Dict { + /// Creates a new empty dictionary. + #[must_use] + pub fn new() -> Self { + Self { + map: HashMap::new(), + // Index 0 is a placeholder since SymbolId uses NonZeroU32. + strings: vec![String::new()], + } + } + + /// Interns a string and returns its `SymbolId`. + /// + /// If the string has been interned before, the existing `SymbolId` is + /// returned. Otherwise, the string is stored and a new `SymbolId` is + /// created. + #[allow(clippy::cast_possible_truncation, clippy::expect_used)] + pub fn intern(&mut self, s: &str) -> SymbolId { + if let Some(&id) = self.map.get(s) { + return id; + } + // strings.len() starts at 1 and only grows, so index >= 1 and + // NonZeroU32::new will never return None. Truncation is acceptable + // because we will never intern more than u32::MAX strings. + let index = self.strings.len() as u32; + let id = SymbolId(NonZeroU32::new(index).expect("symbol index overflow")); + self.strings.push(s.to_owned()); + self.map.insert(s.to_owned(), id); + id + } + + /// Resolves a `SymbolId` back to its string. + /// + /// # Panics + /// + /// Panics if the `SymbolId` was not created by this dictionary. + #[must_use] + pub fn resolve(&self, id: SymbolId) -> &str { + &self.strings[id.0.get() as usize] + } + + /// Returns the number of interned strings. + #[must_use] + pub fn len(&self) -> usize { + self.strings.len() - 1 // subtract the placeholder + } + + /// Returns `true` if the dictionary contains no interned strings. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +impl Default for Dict { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_intern_returns_same_id_for_same_string() { + let mut dict = Dict::new(); + let a = dict.intern("hello"); + let b = dict.intern("hello"); + assert_eq!(a, b); + } + + #[test] + fn test_intern_returns_different_ids_for_different_strings() { + let mut dict = Dict::new(); + let a = dict.intern("hello"); + let b = dict.intern("world"); + assert_ne!(a, b); + } + + #[test] + fn test_resolve_returns_original_string() { + let mut dict = Dict::new(); + let id = dict.intern("test string"); + assert_eq!(dict.resolve(id), "test string"); + } + + #[test] + fn test_len_and_is_empty() { + let mut dict = Dict::new(); + assert!(dict.is_empty()); + assert_eq!(dict.len(), 0); + + dict.intern("a"); + assert!(!dict.is_empty()); + assert_eq!(dict.len(), 1); + + dict.intern("b"); + assert_eq!(dict.len(), 2); + + // Interning duplicate doesn't increase length + dict.intern("a"); + assert_eq!(dict.len(), 2); + } + + #[test] + fn test_empty_string_interning() { + let mut dict = Dict::new(); + let id = dict.intern(""); + assert_eq!(dict.resolve(id), ""); + } + + #[test] + fn test_symbol_id_as_u32() { + let mut dict = Dict::new(); + let id = dict.intern("first"); + assert_eq!(id.as_u32(), 1); // First real entry is index 1 + } +} diff --git a/browser/vendor/xmloxide/src/util/mod.rs b/browser/vendor/xmloxide/src/util/mod.rs new file mode 100644 index 000000000..902e76ccd --- /dev/null +++ b/browser/vendor/xmloxide/src/util/mod.rs @@ -0,0 +1,7 @@ +//! Utility modules for xmloxide. +//! +//! Contains the string interning dictionary, `QName` handling, URI parsing, +//! and growable byte buffer. + +pub mod dict; +pub mod qname; diff --git a/browser/vendor/xmloxide/src/util/qname.rs b/browser/vendor/xmloxide/src/util/qname.rs new file mode 100644 index 000000000..fd65a7a59 --- /dev/null +++ b/browser/vendor/xmloxide/src/util/qname.rs @@ -0,0 +1,64 @@ +//! `QName` (qualified name) handling. +//! +//! A `QName` is a name of the form `prefix:localname` or just `localname` (with +//! no prefix). This module provides utilities for splitting and working with +//! qualified names as defined by the Namespaces in XML 1.0 specification. +//! +//! See <https://www.w3.org/TR/xml-names/#NT-QName> + +/// Splits a `QName` into its prefix and local name parts. +/// +/// Returns `(Some(prefix), localname)` if the name contains a colon, +/// or `(None, localname)` if it does not. +/// +/// # Examples +/// +/// ```ignore +/// use xmloxide::util::qname::split_qname; +/// +/// assert_eq!(split_qname("svg:rect"), (Some("svg"), "rect")); +/// assert_eq!(split_qname("div"), (None, "div")); +/// ``` +#[must_use] +pub fn split_qname(qname: &str) -> (Option<&str>, &str) { + match qname.find(':') { + Some(pos) => (Some(&qname[..pos]), &qname[pos + 1..]), + None => (None, qname), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_qname_with_prefix() { + assert_eq!(split_qname("xml:lang"), (Some("xml"), "lang")); + } + + #[test] + fn test_split_qname_without_prefix() { + assert_eq!(split_qname("div"), (None, "div")); + } + + #[test] + fn test_split_qname_empty() { + assert_eq!(split_qname(""), (None, "")); + } + + #[test] + fn test_split_qname_colon_at_start() { + assert_eq!(split_qname(":local"), (Some(""), "local")); + } + + #[test] + fn test_split_qname_colon_at_end() { + assert_eq!(split_qname("prefix:"), (Some("prefix"), "")); + } + + #[test] + fn test_split_qname_multiple_colons() { + // Only splits on first colon + assert_eq!(split_qname("a:b:c"), (Some("a"), "b:c")); + } +} diff --git a/browser/vendor/xmloxide/src/validation/dtd.rs b/browser/vendor/xmloxide/src/validation/dtd.rs new file mode 100644 index 000000000..da5d17d34 --- /dev/null +++ b/browser/vendor/xmloxide/src/validation/dtd.rs @@ -0,0 +1,3891 @@ +//! DTD (Document Type Definition) data model, parser, and validator. +//! +//! This module implements DTD processing as defined in XML 1.0 (Fifth Edition) +//! sections 2.8, 3.2, 3.3, 3.4, and 4.2. It provides: +//! +//! - A data model for DTD declarations (elements, attributes, entities, notations) +//! - A parser that processes DTD internal subset content +//! - A validator that checks document conformance against a parsed DTD +//! +//! # Content Model Matching +//! +//! The validator implements deterministic content model matching for: +//! - `EMPTY`: element must have no element or text children +//! - `ANY`: any content is allowed +//! - Mixed content `(#PCDATA|a|b)*`: text and listed elements in any order +//! - Element content with sequences `(a,b,c)`, choices `(a|b|c)`, and +//! occurrence indicators `?`, `*`, `+` +//! +//! See XML 1.0 section 3.2 for the full content model specification. + +use std::collections::{HashMap, HashSet}; +use std::fmt; + +use crate::error::{ParseError, SourceLocation}; +use crate::tree::{Document, NodeId, NodeKind}; + +use super::{ValidationError, ValidationResult}; + +// --------------------------------------------------------------------------- +// DTD Data Model +// --------------------------------------------------------------------------- + +/// A parsed DTD containing all declarations from the internal subset. +/// +/// This is the result of [`parse_dtd`] and serves as input to [`validate`]. +#[derive(Debug, Clone, Default)] +pub struct Dtd { + /// Element declarations, keyed by element name. + pub elements: HashMap<String, ElementDecl>, + /// Attribute declarations, keyed by `(element_name, attribute_name)`. + pub attributes: HashMap<String, Vec<AttributeDecl>>, + /// General entity declarations, keyed by entity name. + pub entities: HashMap<String, EntityDecl>, + /// Parameter entity declarations, keyed by entity name. + pub param_entities: HashMap<String, EntityDecl>, + /// Notation declarations, keyed by notation name. + pub notations: HashMap<String, NotationDecl>, + /// Ordered list of all declarations (preserving source order and comments). + pub declarations: Vec<DtdDeclaration>, +} + +/// A single DTD declaration, preserving source order for re-serialization. +#[derive(Debug, Clone)] +pub enum DtdDeclaration { + /// An element declaration. + Element(ElementDecl), + /// A single attribute declaration (one per attribute, even if the source + /// used a multi-attribute ATTLIST). + Attlist(AttributeDecl), + /// A general entity declaration. + Entity(EntityDecl), + /// A notation declaration. + Notation(NotationDecl), + /// A comment. + Comment(String), + /// A processing instruction. + Pi(String, Option<String>), +} + +/// An element declaration from `<!ELEMENT name content-model>`. +/// +/// See XML 1.0 section 3.2. +#[derive(Debug, Clone)] +pub struct ElementDecl { + /// The element name. + pub name: String, + /// The declared content model. + pub content_model: ContentModel, +} + +/// The content model for an element declaration. +/// +/// See XML 1.0 section 3.2 for the grammar: +/// - `contentspec ::= 'EMPTY' | 'ANY' | Mixed | children` +#[derive(Debug, Clone, PartialEq)] +pub enum ContentModel { + /// The element must have no children (no elements, no text). + /// Declared as `<!ELEMENT name EMPTY>`. + Empty, + /// Any content is allowed. + /// Declared as `<!ELEMENT name ANY>`. + Any, + /// Mixed content: text and optionally listed elements in any order. + /// Declared as `<!ELEMENT name (#PCDATA)>` or `<!ELEMENT name (#PCDATA|a|b)*>`. + /// + /// The `Vec<String>` contains the allowed element names (empty for `#PCDATA` only). + Mixed(Vec<String>), + /// Element-only content following a content spec pattern. + /// Declared as `<!ELEMENT name (a,b,c)>` etc. + Children(ContentSpec), +} + +/// A content specification for element-only content models. +/// +/// Represents the recursive structure of `(a,b)`, `(a|b)`, etc. +/// with occurrence indicators. +/// +/// See XML 1.0 section 3.2.1 and 3.2.2. +#[derive(Debug, Clone, PartialEq)] +pub struct ContentSpec { + /// The content particle kind. + pub kind: ContentSpecKind, + /// How many times this particle may occur. + pub occurrence: Occurrence, +} + +/// The kind of a content specification particle. +#[derive(Debug, Clone, PartialEq)] +pub enum ContentSpecKind { + /// A single named element, e.g., `a`. + Name(String), + /// A sequence of particles, e.g., `(a, b, c)`. + Seq(Vec<ContentSpec>), + /// A choice among particles, e.g., `(a | b | c)`. + Choice(Vec<ContentSpec>), +} + +/// Occurrence indicator for a content particle. +/// +/// See XML 1.0 section 3.2.1: `'?' | '*' | '+'`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Occurrence { + /// Exactly once (no indicator). + Once, + /// Zero or one time (`?`). + Optional, + /// Zero or more times (`*`). + ZeroOrMore, + /// One or more times (`+`). + OneOrMore, +} + +/// An attribute declaration from `<!ATTLIST element-name attr-name type default>`. +/// +/// See XML 1.0 section 3.3. +#[derive(Debug, Clone)] +pub struct AttributeDecl { + /// The element this attribute belongs to. + pub element_name: String, + /// The attribute name. + pub attribute_name: String, + /// The attribute type. + pub attribute_type: AttributeType, + /// The default value specification. + pub default: AttributeDefault, +} + +/// The type of an attribute as declared in `<!ATTLIST>`. +/// +/// See XML 1.0 section 3.3.1. +#[derive(Debug, Clone, PartialEq)] +pub enum AttributeType { + /// Character data (`CDATA`). + CData, + /// A unique identifier (`ID`). + Id, + /// A reference to an ID (`IDREF`). + IdRef, + /// Space-separated list of ID references (`IDREFS`). + IdRefs, + /// An entity name (`ENTITY`). + Entity, + /// Space-separated list of entity names (`ENTITIES`). + Entities, + /// A name token (`NMTOKEN`). + NmToken, + /// Space-separated list of name tokens (`NMTOKENS`). + NmTokens, + /// A notation type with allowed notation names (`NOTATION (a|b|c)`). + Notation(Vec<String>), + /// An enumeration of allowed values (`(a|b|c)`). + Enumeration(Vec<String>), +} + +/// The default value specification for an attribute. +/// +/// See XML 1.0 section 3.3.2. +#[derive(Debug, Clone, PartialEq)] +pub enum AttributeDefault { + /// The attribute is required (`#REQUIRED`). + Required, + /// The attribute is optional with no default (`#IMPLIED`). + Implied, + /// The attribute has a fixed value (`#FIXED "value"`). + Fixed(String), + /// The attribute has a default value (`"value"`). + Default(String), +} + +/// A general entity declaration. +/// +/// See XML 1.0 section 4.2. +#[derive(Debug, Clone)] +pub struct EntityDecl { + /// The entity name. + pub name: String, + /// The entity's value, either internal or external. + pub kind: EntityKind, +} + +/// Whether an entity is internal (has a literal value) or external +/// (references an external resource). +#[derive(Debug, Clone)] +pub enum EntityKind { + /// Internal entity with a literal replacement text. + Internal(String), + /// External entity identified by a system URI and optional public ID. + External { + /// The SYSTEM identifier (URI). + system_id: String, + /// The PUBLIC identifier, if any. + public_id: Option<String>, + }, +} + +/// A notation declaration from `<!NOTATION name ...>`. +/// +/// See XML 1.0 section 4.7. +#[derive(Debug, Clone)] +pub struct NotationDecl { + /// The notation name. + pub name: String, + /// The SYSTEM identifier, if any. + pub system_id: Option<String>, + /// The PUBLIC identifier, if any. + pub public_id: Option<String>, +} + +impl fmt::Display for ContentModel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "EMPTY"), + Self::Any => write!(f, "ANY"), + Self::Mixed(names) => { + if names.is_empty() { + write!(f, "(#PCDATA)") + } else { + write!(f, "(#PCDATA|{})*", names.join("|")) + } + } + Self::Children(spec) => write!(f, "{spec}"), + } + } +} + +impl fmt::Display for ContentSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.kind { + ContentSpecKind::Name(name) => write!(f, "{name}")?, + ContentSpecKind::Seq(items) => { + write!(f, "(")?; + for (i, item) in items.iter().enumerate() { + if i > 0 { + write!(f, " , ")?; + } + write!(f, "{item}")?; + } + write!(f, ")")?; + } + ContentSpecKind::Choice(items) => { + write!(f, "(")?; + for (i, item) in items.iter().enumerate() { + if i > 0 { + write!(f, " | ")?; + } + write!(f, "{item}")?; + } + write!(f, ")")?; + } + } + match self.occurrence { + Occurrence::Once => {} + Occurrence::Optional => write!(f, "?")?, + Occurrence::ZeroOrMore => write!(f, "*")?, + Occurrence::OneOrMore => write!(f, "+")?, + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// DTD Serializer +// --------------------------------------------------------------------------- + +/// Serializes a parsed DTD's declarations into the internal subset format +/// used by libxml2. +/// +/// Each declaration appears on its own line. The output does NOT include +/// the surrounding `[` and `]>` — the caller adds those. +#[must_use] +#[allow(clippy::too_many_lines)] +pub fn serialize_dtd(dtd: &Dtd) -> String { + let mut out = String::new(); + let mut last_was_comment = false; + + for decl in &dtd.declarations { + // Don't add a newline before declarations that immediately follow + // a comment — the comment text already contains any needed whitespace. + // libxml2 concatenates the comment closing `-->` and the next + // declaration on the same line. + if !last_was_comment { + out.push('\n'); + } + match decl { + DtdDeclaration::Element(e) => { + out.push_str("<!ELEMENT "); + out.push_str(&e.name); + out.push(' '); + write_content_model(&mut out, &e.content_model); + out.push('>'); + last_was_comment = false; + } + DtdDeclaration::Attlist(a) => { + out.push_str("<!ATTLIST "); + out.push_str(&a.element_name); + out.push(' '); + out.push_str(&a.attribute_name); + out.push(' '); + write_attribute_type(&mut out, &a.attribute_type); + out.push(' '); + write_attribute_default(&mut out, &a.default); + out.push('>'); + last_was_comment = false; + } + DtdDeclaration::Entity(e) => { + out.push_str("<!ENTITY "); + out.push_str(&e.name); + match &e.kind { + EntityKind::Internal(value) => { + out.push(' '); + write_entity_value(&mut out, value); + } + EntityKind::External { + system_id, + public_id, + } => { + if let Some(pub_id) = public_id { + out.push_str(" PUBLIC \""); + out.push_str(pub_id); + out.push_str("\" \""); + out.push_str(system_id); + out.push('"'); + } else { + out.push_str(" SYSTEM \""); + out.push_str(system_id); + out.push('"'); + } + } + } + out.push('>'); + last_was_comment = false; + } + DtdDeclaration::Notation(n) => { + out.push_str("<!NOTATION "); + out.push_str(&n.name); + match (&n.public_id, &n.system_id) { + (Some(pub_id), Some(sys_id)) => { + out.push_str(" PUBLIC \""); + out.push_str(pub_id); + out.push_str("\" \""); + out.push_str(sys_id); + out.push('"'); + } + (Some(pub_id), None) => { + out.push_str(" PUBLIC \""); + out.push_str(pub_id); + out.push('"'); + } + (None, Some(sys_id)) => { + out.push_str(" SYSTEM \""); + out.push_str(sys_id); + out.push('"'); + } + (None, None) => {} + } + out.push('>'); + last_was_comment = false; + } + DtdDeclaration::Comment(text) => { + out.push_str("<!--"); + out.push_str(text); + out.push_str("-->"); + last_was_comment = true; + } + DtdDeclaration::Pi(target, data) => { + out.push_str("<?"); + out.push_str(target); + if let Some(d) = data { + out.push(' '); + out.push_str(d); + } + out.push_str("?>"); + last_was_comment = false; + } + } + } + + // libxml2 adds a newline before ]> unless the last item was a comment. + if !last_was_comment && !dtd.declarations.is_empty() { + out.push('\n'); + } + + out +} + +/// Writes a content model in libxml2's format. +fn write_content_model(out: &mut String, model: &ContentModel) { + match model { + ContentModel::Empty => out.push_str("EMPTY"), + ContentModel::Any => out.push_str("ANY"), + ContentModel::Mixed(names) => { + if names.is_empty() { + out.push_str("(#PCDATA)"); + } else { + out.push_str("(#PCDATA"); + for name in names { + out.push_str(" | "); + out.push_str(name); + } + out.push_str(")*"); + } + } + ContentModel::Children(spec) => { + use std::fmt::Write; + let _ = write!(out, "{spec}"); + } + } +} + +/// Writes an attribute type in libxml2's format. +fn write_attribute_type(out: &mut String, attr_type: &AttributeType) { + match attr_type { + AttributeType::CData => out.push_str("CDATA"), + AttributeType::Id => out.push_str("ID"), + AttributeType::IdRef => out.push_str("IDREF"), + AttributeType::IdRefs => out.push_str("IDREFS"), + AttributeType::Entity => out.push_str("ENTITY"), + AttributeType::Entities => out.push_str("ENTITIES"), + AttributeType::NmToken => out.push_str("NMTOKEN"), + AttributeType::NmTokens => out.push_str("NMTOKENS"), + AttributeType::Notation(values) | AttributeType::Enumeration(values) => { + if matches!(attr_type, AttributeType::Notation(_)) { + out.push_str("NOTATION "); + } + out.push('('); + for (i, v) in values.iter().enumerate() { + if i > 0 { + out.push_str(" | "); + } + out.push_str(v); + } + out.push(')'); + } + } +} + +/// Writes an attribute default in libxml2's format. +fn write_attribute_default(out: &mut String, default: &AttributeDefault) { + match default { + AttributeDefault::Required => out.push_str("#REQUIRED"), + AttributeDefault::Implied => out.push_str("#IMPLIED"), + AttributeDefault::Fixed(value) => { + out.push_str("#FIXED \""); + out.push_str(value); + out.push('"'); + } + AttributeDefault::Default(value) => { + out.push('"'); + out.push_str(value); + out.push('"'); + } + } +} + +/// Escapes an entity value for DTD serialization. +/// +/// Entity references (`&name;`) and character references (`&#...;`) within +/// the value are preserved as-is (matching libxml2 behavior). Only standalone +/// `&` characters are escaped. The quote character is chosen to minimize +/// escaping: single quotes when the value contains double quotes. +fn write_entity_value(out: &mut String, value: &str) { + // Choose quote character: use single quotes if value contains double quotes + // but not single quotes (avoids escaping). Otherwise use double quotes. + let quote = if value.contains('"') && !value.contains('\'') { + '\'' + } else { + '"' + }; + out.push(quote); + + let bytes = value.as_bytes(); + let len = bytes.len(); + let mut i = 0; + + while i < len { + if bytes[i] == b'&' { + // Check if this is a valid entity or character reference — if so, pass through. + if let Some(ref_end) = find_reference_end(bytes, i) { + // Copy the reference as-is + let ref_str = std::str::from_utf8(&bytes[i..=ref_end]).unwrap_or("&amp;"); + out.push_str(ref_str); + i = ref_end + 1; + } else { + out.push_str("&amp;"); + i += 1; + } + } else if bytes[i] == b'%' { + out.push_str("&#37;"); + i += 1; + } else if bytes[i] == quote as u8 { + if quote == '"' { + out.push_str("&quot;"); + } else { + out.push_str("&apos;"); + } + i += 1; + } else { + // Push the char (may be multi-byte UTF-8) + let ch = &value[i..]; + if let Some(c) = ch.chars().next() { + out.push(c); + i += c.len_utf8(); + } else { + i += 1; + } + } + } + + out.push(quote); +} + +/// Finds the end position (inclusive, the `;`) of an entity or character +/// reference starting at `start` in `bytes`. Returns `None` if the `&` at +/// `start` is not the beginning of a valid reference. +fn find_reference_end(bytes: &[u8], start: usize) -> Option<usize> { + if start >= bytes.len() || bytes[start] != b'&' { + return None; + } + let mut i = start + 1; + if i >= bytes.len() { + return None; + } + + if bytes[i] == b'#' { + // Character reference: &#digits; or &#xhexdigits; + i += 1; + if i >= bytes.len() { + return None; + } + if bytes[i] == b'x' { + i += 1; + let digit_start = i; + while i < bytes.len() && bytes[i].is_ascii_hexdigit() { + i += 1; + } + if i == digit_start || i >= bytes.len() || bytes[i] != b';' { + return None; + } + } else { + let digit_start = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + if i == digit_start || i >= bytes.len() || bytes[i] != b';' { + return None; + } + } + Some(i) + } else { + // Named entity reference: &name; + // Name must start with a name start char (letter or _) + if !is_name_start_byte(bytes[i]) { + return None; + } + i += 1; + while i < bytes.len() && is_name_byte(bytes[i]) { + i += 1; + } + if i >= bytes.len() || bytes[i] != b';' { + return None; + } + Some(i) + } +} + +/// Checks if a byte is valid as the start of an XML name. +fn is_name_start_byte(b: u8) -> bool { + b.is_ascii_alphabetic() || b == b'_' || b == b':' +} + +/// Checks if a byte is valid within an XML name. +fn is_name_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || b == b':' || b == b'-' || b == b'.' +} + +// --------------------------------------------------------------------------- +// DTD Parser +// --------------------------------------------------------------------------- + +/// Parses a DTD internal subset string into a [`Dtd`] data structure. +/// +/// The input should be the content from inside `<!DOCTYPE root [ ... ]>`, +/// i.e., just the internal subset without the surrounding brackets. +/// +/// # Errors +/// +/// Returns a `ParseError` if the DTD content is malformed. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::validation::dtd::parse_dtd; +/// +/// let dtd = parse_dtd("<!ELEMENT root (#PCDATA)>").unwrap(); +/// assert!(dtd.elements.contains_key("root")); +/// ``` +pub fn parse_dtd(input: &str) -> Result<Dtd, ParseError> { + let mut parser = DtdParser::new(input); + parser.parse() +} + +/// Maximum nesting depth for `<!ELEMENT>` content-model groups. +/// +/// Bounds the mutual recursion between [`DtdParser::parse_content_spec_group`] +/// and [`DtdParser::parse_content_particle`] so that a deeply-nested +/// parenthesised content model cannot exhaust the stack (CWE-674). Matches +/// the spirit of the parser's `DEFAULT_MAX_DEPTH`, which bounds element +/// nesting but does not reach the DTD content-model grammar. +const MAX_CONTENT_MODEL_DEPTH: u32 = 256; + +/// Internal DTD parser state. +struct DtdParser<'a> { + input: &'a [u8], + pos: usize, + line: u32, + column: u32, + dtd: Dtd, +} + +impl<'a> DtdParser<'a> { + fn new(input: &'a str) -> Self { + Self { + input: input.as_bytes(), + pos: 0, + line: 1, + column: 1, + dtd: Dtd::default(), + } + } + + fn parse(&mut self) -> Result<Dtd, ParseError> { + loop { + self.skip_whitespace(); + if self.at_end() { + break; + } + + if self.looking_at(b"<!--") { + self.parse_comment_decl()?; + } else if self.looking_at(b"<!ELEMENT") { + self.parse_element_decl()?; + } else if self.looking_at(b"<!ATTLIST") { + self.parse_attlist_decl()?; + } else if self.looking_at(b"<!ENTITY") { + self.parse_entity_decl()?; + } else if self.looking_at(b"<!NOTATION") { + self.parse_notation_decl()?; + } else if self.looking_at(b"<?") { + self.parse_pi_decl()?; + } else if self.peek() == Some(b'%') { + // Parameter entity reference — skip it since we don't expand + self.skip_pe_reference()?; + } else { + return Err(self.fatal(format!( + "unexpected character '{}' in DTD", + self.peek().map_or('?', |b| b as char) + ))); + } + } + + self.post_validate()?; + + Ok(std::mem::take(&mut self.dtd)) + } + + /// Post-parse validation checks that require the complete entity map. + /// + /// Detects entity recursion (WFC: No Recursion), validates that entity + /// references in attribute defaults refer to internal parsed entities, + /// and checks for `<` in entity replacement text used in attributes. + fn post_validate(&self) -> Result<(), ParseError> { + // Check for entity recursion (WFC: No Recursion, XML 1.0 §4.1). + // `safe` memoizes entities already proven acyclic so the walk is + // linear in the total size of the entity declarations rather than + // exponential in chain depth. + let mut safe = std::collections::HashSet::new(); + for (name, decl) in &self.dtd.entities { + if let EntityKind::Internal(ref value) = decl.kind { + if safe.contains(name.as_str()) { + continue; + } + let mut visited = std::collections::HashSet::new(); + visited.insert(name.clone()); + self.check_entity_recursion(value, &mut visited, &mut safe)?; + safe.insert(name.clone()); + } + } + + // Check for parameter entity recursion (WFC: No Recursion, XML 1.0 §4.1). + // PE values may contain encoded PE references via &#37; (which is '%'). + // After char ref expansion, if %name; appears in its own value, that's + // direct or indirect recursion. + let mut safe = std::collections::HashSet::new(); + for (name, decl) in &self.dtd.param_entities { + if let EntityKind::Internal(ref value) = decl.kind { + if safe.contains(name.as_str()) { + continue; + } + let expanded = expand_char_refs_only(value); + let mut visited = std::collections::HashSet::new(); + visited.insert(name.clone()); + self.check_pe_recursion(&expanded, &mut visited, &mut safe)?; + safe.insert(name.clone()); + } + } + + // Validate entity replacement text after character reference + // expansion (XML 1.0 §4.5). Character references in entity + // values are expanded at declaration time. The resulting + // replacement text must be well-formed when re-parsed. + for (name, decl) in &self.dtd.entities { + if let EntityKind::Internal(ref value) = decl.kind { + self.validate_replacement_text(name, value)?; + } + } + + // Validate predefined entity redeclarations (XML 1.0 §4.6). + // If lt, gt, amp, apos, or quot are declared, their replacement + // text must be a character reference to the respective character. + self.validate_predefined_entities()?; + + // Note: content production validation (XML 1.0 §4.3.2) is + // performed at entity expansion time in the XML parser, not + // here, because it only applies to entities that are actually + // referenced in the document. + + // Validate entity references in ATTLIST defaults. `checked` + // memoizes entities already validated so shared references are + // walked once (the reference graph is acyclic at this point). + let mut checked = std::collections::HashSet::new(); + for attrs in self.dtd.attributes.values() { + for attr in attrs { + let (AttributeDefault::Default(default_value) + | AttributeDefault::Fixed(default_value)) = &attr.default + else { + continue; + }; + self.validate_attr_default_entities(default_value, &mut checked)?; + } + } + + Ok(()) + } + + /// Validates that predefined entity redeclarations (lt, gt, amp, apos, + /// quot) use the correct character reference as replacement text. + /// + /// Per XML 1.0 §4.6: "If the entities lt or amp are declared, they MUST + /// be declared as internal entities whose replacement text is a character + /// reference to the respective character." + fn validate_predefined_entities(&self) -> Result<(), ParseError> { + // Per XML 1.0 §4.6: lt and amp MUST use character references. + // gt, apos, and quot may use either the literal character or a + // character reference. + let expected: &[(&str, &str, &[&str])] = &[ + ("lt", "<", &["&#60;", "&#x3C;", "&#x3c;"]), + ("gt", ">", &[">", "&#62;", "&#x3E;", "&#x3e;"]), + ("amp", "&", &["&#38;", "&#x26;"]), + ("apos", "'", &["'", "&#39;", "&#x27;"]), + ("quot", "\"", &["\"", "&#34;", "&#x22;"]), + ]; + for &(name, _char_val, valid_refs) in expected { + if let Some(decl) = self.dtd.entities.get(name) { + match &decl.kind { + EntityKind::Internal(value) => { + // Check if the value is a valid character reference + // for this predefined entity. + if !valid_refs.iter().any(|r| r == value) { + return Err(self.fatal(format!( + "predefined entity '{name}' must be declared as \ + a character reference (e.g., '{}')", + valid_refs[0] + ))); + } + } + EntityKind::External { .. } => { + return Err(self.fatal(format!( + "predefined entity '{name}' must be an internal entity" + ))); + } + } + } + } + Ok(()) + } + + /// Validates entity replacement text after character reference + /// expansion per XML 1.0 §4.5. + /// + /// Expands only character references in the entity value (not entity + /// references), then checks the resulting replacement text for basic + /// well-formedness: bare `&` characters from `&#38;` expansion that + /// don't form valid references are rejected. + fn validate_replacement_text(&self, entity_name: &str, value: &str) -> Result<(), ParseError> { + // Only check values that contain character references + if !value.contains("&#") { + return Ok(()); + } + + // Build replacement text by expanding only character references + let replacement = Self::expand_char_refs_only(value); + + // Check for bare '&' in the replacement text that don't form + // valid entity or character references + let bytes = replacement.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'&' { + i += 1; + if i >= bytes.len() { + return Err(self.fatal(format!( + "entity '{entity_name}' replacement text contains \ + bare '&' at end of text" + ))); + } + if bytes[i] == b'#' { + // Character reference — check it's complete + i += 1; + let has_digits = if i < bytes.len() && bytes[i] == b'x' { + i += 1; + let start = i; + while i < bytes.len() && bytes[i].is_ascii_hexdigit() { + i += 1; + } + i > start + } else { + let start = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + i > start + }; + if !has_digits || i >= bytes.len() || bytes[i] != b';' { + return Err(self.fatal(format!( + "entity '{entity_name}' replacement text contains \ + incomplete character reference" + ))); + } + i += 1; + } else if bytes[i].is_ascii_alphabetic() || bytes[i] == b'_' || bytes[i] == b':' { + // Entity reference — skip name + while i < bytes.len() && bytes[i] != b';' { + i += 1; + } + if i >= bytes.len() { + return Err(self.fatal(format!( + "entity '{entity_name}' replacement text contains \ + incomplete entity reference" + ))); + } + i += 1; + } else { + return Err(self.fatal(format!( + "entity '{entity_name}' replacement text contains \ + bare '&' not followed by a valid reference" + ))); + } + } else { + i += 1; + } + } + Ok(()) + } + + /// Expands only character references in a string, leaving entity + /// references as-is. Returns the expanded text. + fn expand_char_refs_only(value: &str) -> String { + expand_char_refs_only(value) + } + + /// Recursively checks for entity reference cycles. + fn check_entity_recursion( + &self, + value: &str, + visited: &mut std::collections::HashSet<String>, + safe: &mut std::collections::HashSet<String>, + ) -> Result<(), ParseError> { + for ref_name in Self::extract_entity_refs(value) { + if safe.contains(ref_name) { + continue; + } + if visited.contains(ref_name) { + return Err(self.fatal(format!("recursive entity reference: '{ref_name}'"))); + } + if let Some(decl) = self.dtd.entities.get(ref_name) { + if let EntityKind::Internal(ref inner_value) = decl.kind { + visited.insert(ref_name.to_string()); + self.check_entity_recursion(inner_value, visited, safe)?; + visited.remove(ref_name); + safe.insert(ref_name.to_string()); + } + } + } + Ok(()) + } + + /// Recursively checks for parameter entity reference cycles. + /// + /// Examines the char-ref-expanded replacement text for `%name;` patterns. + fn check_pe_recursion( + &self, + value: &str, + visited: &mut std::collections::HashSet<String>, + safe: &mut std::collections::HashSet<String>, + ) -> Result<(), ParseError> { + for ref_name in Self::extract_pe_refs(value) { + if safe.contains(&ref_name) { + continue; + } + if visited.contains(&ref_name) { + return Err(self.fatal(format!( + "recursive parameter entity reference: '%{ref_name}'" + ))); + } + if let Some(decl) = self.dtd.param_entities.get(&ref_name) { + if let EntityKind::Internal(ref inner_value) = decl.kind { + let expanded = expand_char_refs_only(inner_value); + visited.insert(ref_name.clone()); + self.check_pe_recursion(&expanded, visited, safe)?; + visited.remove(&ref_name); + safe.insert(ref_name.clone()); + } + } + } + Ok(()) + } + + /// Extracts parameter entity reference names (`%name;`) from a string. + fn extract_pe_refs(value: &str) -> Vec<String> { + let mut refs = Vec::new(); + let bytes = value.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + i += 1; + if i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'_') { + let start = i; + while i < bytes.len() && bytes[i] != b';' && !bytes[i].is_ascii_whitespace() { + i += 1; + } + if i < bytes.len() && bytes[i] == b';' && i > start { + if let Ok(name) = std::str::from_utf8(&bytes[start..i]) { + refs.push(name.to_string()); + } + i += 1; + } + } + } else { + i += 1; + } + } + refs + } + + /// Validates entity references in attribute default values. + /// + /// Checks WFC: No External Entity References (§3.1) and + /// WFC: No `<` in Attribute Values for entity replacement text. + fn validate_attr_default_entities( + &self, + value: &str, + checked: &mut std::collections::HashSet<String>, + ) -> Result<(), ParseError> { + for ref_name in Self::extract_entity_refs(value) { + // Built-in entities are always fine + if matches!(ref_name, "amp" | "lt" | "gt" | "apos" | "quot") { + continue; + } + if checked.contains(ref_name) { + continue; + } + match self.dtd.entities.get(ref_name) { + None => { + return Err(self.fatal(format!( + "undeclared entity '{ref_name}' referenced in \ + attribute default value" + ))); + } + Some(decl) => match &decl.kind { + EntityKind::External { .. } => { + return Err(self.fatal(format!( + "attribute default value must not reference \ + external entity '{ref_name}'" + ))); + } + EntityKind::Internal(ref text) => { + // Check for '<' in replacement text (WFC: No < in + // Attribute Values, XML 1.0 §3.1) + if text.contains('<') { + return Err(self.fatal(format!( + "entity '{ref_name}' contains '<' and cannot \ + be used in attribute values" + ))); + } + // Recursively check referenced entities + checked.insert(ref_name.to_string()); + self.validate_attr_default_entities(text, checked)?; + } + }, + } + } + Ok(()) + } + + /// Extracts entity reference names from a string value. + /// + /// Returns an iterator over entity names found in `&name;` patterns, + /// excluding character references (`&#...;`). + fn extract_entity_refs(value: &str) -> Vec<&str> { + let mut refs = Vec::new(); + let bytes = value.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'&' { + i += 1; + if i < bytes.len() && bytes[i] == b'#' { + // Character reference — skip + while i < bytes.len() && bytes[i] != b';' { + i += 1; + } + if i < bytes.len() { + i += 1; + } + } else { + // Entity reference + let start = i; + while i < bytes.len() && bytes[i] != b';' && bytes[i] != b'&' { + i += 1; + } + if i < bytes.len() && bytes[i] == b';' && i > start { + if let Ok(name) = std::str::from_utf8(&bytes[start..i]) { + refs.push(name); + } + i += 1; + } + } + } else { + i += 1; + } + } + refs + } + + // --- ELEMENT declaration --- + // See XML 1.0 §3.2: [45] elementdecl + + fn parse_element_decl(&mut self) -> Result<(), ParseError> { + self.expect_str(b"<!ELEMENT")?; + self.skip_whitespace_required()?; + let name = self.parse_name()?; + self.skip_whitespace_required()?; + let content_model = self.parse_content_model()?; + self.skip_whitespace(); + self.expect_byte(b'>')?; + + let decl = ElementDecl { + name: name.clone(), + content_model, + }; + self.dtd + .declarations + .push(DtdDeclaration::Element(decl.clone())); + self.dtd.elements.insert(name, decl); + Ok(()) + } + + fn parse_content_model(&mut self) -> Result<ContentModel, ParseError> { + if self.looking_at(b"EMPTY") { + self.expect_str(b"EMPTY")?; + return Ok(ContentModel::Empty); + } + if self.looking_at(b"ANY") { + self.expect_str(b"ANY")?; + return Ok(ContentModel::Any); + } + + // Must be Mixed or Children, both start with '(' + self.expect_byte(b'(')?; + self.skip_whitespace(); + + // Check for mixed content: (#PCDATA ...) + if self.looking_at(b"#PCDATA") { + self.expect_str(b"#PCDATA")?; + self.skip_whitespace(); + + let mut names = Vec::new(); + + if self.peek() == Some(b')') { + // (#PCDATA) — text only + self.advance(1); + // Optional '*' after (#PCDATA) — some DTDs write (#PCDATA)* + if self.peek() == Some(b'*') { + self.advance(1); + } + return Ok(ContentModel::Mixed(names)); + } + + // (#PCDATA|a|b)* + while self.peek() == Some(b'|') { + self.advance(1); + self.skip_whitespace(); + let elem_name = self.parse_name()?; + names.push(elem_name); + self.skip_whitespace(); + } + + self.expect_byte(b')')?; + self.expect_byte(b'*')?; + + return Ok(ContentModel::Mixed(names)); + } + + // Element-only content: parse as a content spec group. The opening + // '(' has already been consumed, so we are one level deep. + let spec = self.parse_content_spec_group(1)?; + Ok(ContentModel::Children(spec)) + } + + /// Parses a content spec starting after the opening '(' has been consumed + /// and the first item is NOT `#PCDATA`. + /// + /// `depth` is the current parenthesis-nesting level (1 for the outermost + /// group). It bounds the mutual recursion with `parse_content_particle` so + /// that untrusted input cannot exhaust the stack; see + /// [`MAX_CONTENT_MODEL_DEPTH`]. + fn parse_content_spec_group(&mut self, depth: u32) -> Result<ContentSpec, ParseError> { + if depth > MAX_CONTENT_MODEL_DEPTH { + return Err(self.fatal(format!( + "content model nesting exceeds maximum depth of {MAX_CONTENT_MODEL_DEPTH}" + ))); + } + let mut first = self.parse_content_particle(depth)?; + self.skip_whitespace(); + + // Determine if this is a sequence (,) or choice (|) + if self.peek() == Some(b',') { + // Sequence + let mut items = vec![first]; + while self.peek() == Some(b',') { + self.advance(1); + self.skip_whitespace(); + let item = self.parse_content_particle(depth)?; + items.push(item); + self.skip_whitespace(); + } + self.expect_byte(b')')?; + let occurrence = self.parse_occurrence(); + Ok(ContentSpec { + kind: ContentSpecKind::Seq(items), + occurrence, + }) + } else if self.peek() == Some(b'|') { + // Choice + let mut items = vec![first]; + while self.peek() == Some(b'|') { + self.advance(1); + self.skip_whitespace(); + let item = self.parse_content_particle(depth)?; + items.push(item); + self.skip_whitespace(); + } + self.expect_byte(b')')?; + let occurrence = self.parse_occurrence(); + Ok(ContentSpec { + kind: ContentSpecKind::Choice(items), + occurrence, + }) + } else { + // Single item group: (item)?/* + self.expect_byte(b')')?; + let group_occurrence = self.parse_occurrence(); + + if group_occurrence != Occurrence::Once { + // Group has occurrence: (X)+ → wrap in Seq + Ok(ContentSpec { + kind: ContentSpecKind::Seq(vec![first]), + occurrence: group_occurrence, + }) + } else if first.occurrence != Occurrence::Once { + // Inner particle has occurrence but group doesn't. + // libxml2 normalizes (X+) → (X)+ by moving occurrence + // to the outer group. + let inner_occ = first.occurrence; + first.occurrence = Occurrence::Once; + Ok(ContentSpec { + kind: ContentSpecKind::Seq(vec![first]), + occurrence: inner_occ, + }) + } else { + // No occurrence on either — unwrap the group + Ok(first) + } + } + } + + /// Parses a single content particle: a name with an optional occurrence + /// indicator, or a nested parenthesised group. + /// + /// `depth` is the enclosing parenthesis-nesting level; a nested group + /// recurses at `depth + 1`. See [`MAX_CONTENT_MODEL_DEPTH`]. + fn parse_content_particle(&mut self, depth: u32) -> Result<ContentSpec, ParseError> { + if self.peek() == Some(b'(') { + self.advance(1); + self.skip_whitespace(); + self.parse_content_spec_group(depth + 1) + } else { + let name = self.parse_name()?; + let occurrence = self.parse_occurrence(); + Ok(ContentSpec { + kind: ContentSpecKind::Name(name), + occurrence, + }) + } + } + + fn parse_occurrence(&mut self) -> Occurrence { + match self.peek() { + Some(b'?') => { + self.advance(1); + Occurrence::Optional + } + Some(b'*') => { + self.advance(1); + Occurrence::ZeroOrMore + } + Some(b'+') => { + self.advance(1); + Occurrence::OneOrMore + } + _ => Occurrence::Once, + } + } + + // --- ATTLIST declaration --- + // See XML 1.0 §3.3: [52] AttlistDecl + + fn parse_attlist_decl(&mut self) -> Result<(), ParseError> { + self.expect_str(b"<!ATTLIST")?; + self.skip_whitespace_required()?; + let element_name = self.parse_name()?; + + loop { + self.skip_whitespace(); + if self.peek() == Some(b'>') { + self.advance(1); + break; + } + + let attribute_name = self.parse_name()?; + self.skip_whitespace_required()?; + let attribute_type = self.parse_attribute_type()?; + self.skip_whitespace_required()?; + let default = self.parse_attribute_default()?; + + let decl = AttributeDecl { + element_name: element_name.clone(), + attribute_name, + attribute_type, + default, + }; + + // Per XML 1.0 §3.3, the first attribute declaration is binding; + // subsequent declarations for the same attribute are ignored. + let attrs = self.dtd.attributes.entry(element_name.clone()).or_default(); + if !attrs + .iter() + .any(|a| a.attribute_name == decl.attribute_name) + { + self.dtd + .declarations + .push(DtdDeclaration::Attlist(decl.clone())); + attrs.push(decl); + } + } + + Ok(()) + } + + fn parse_attribute_type(&mut self) -> Result<AttributeType, ParseError> { + if self.looking_at(b"CDATA") { + self.expect_str(b"CDATA")?; + Ok(AttributeType::CData) + } else if self.looking_at(b"IDREFS") { + self.expect_str(b"IDREFS")?; + Ok(AttributeType::IdRefs) + } else if self.looking_at(b"IDREF") { + self.expect_str(b"IDREF")?; + Ok(AttributeType::IdRef) + } else if self.looking_at(b"ID") { + self.expect_str(b"ID")?; + Ok(AttributeType::Id) + } else if self.looking_at(b"ENTITIES") { + self.expect_str(b"ENTITIES")?; + Ok(AttributeType::Entities) + } else if self.looking_at(b"ENTITY") { + self.expect_str(b"ENTITY")?; + Ok(AttributeType::Entity) + } else if self.looking_at(b"NMTOKENS") { + self.expect_str(b"NMTOKENS")?; + Ok(AttributeType::NmTokens) + } else if self.looking_at(b"NMTOKEN") { + self.expect_str(b"NMTOKEN")?; + Ok(AttributeType::NmToken) + } else if self.looking_at(b"NOTATION") { + self.expect_str(b"NOTATION")?; + self.skip_whitespace_required()?; + let values = self.parse_enumerated_values()?; + Ok(AttributeType::Notation(values)) + } else if self.peek() == Some(b'(') { + let values = self.parse_enumerated_values()?; + Ok(AttributeType::Enumeration(values)) + } else { + Err(self.fatal("expected attribute type")) + } + } + + fn parse_enumerated_values(&mut self) -> Result<Vec<String>, ParseError> { + self.expect_byte(b'(')?; + self.skip_whitespace(); + let mut values = Vec::new(); + + let first = self.parse_nmtoken()?; + values.push(first); + + loop { + self.skip_whitespace(); + if self.peek() == Some(b')') { + self.advance(1); + break; + } + self.expect_byte(b'|')?; + self.skip_whitespace(); + let val = self.parse_nmtoken()?; + values.push(val); + } + + Ok(values) + } + + fn parse_attribute_default(&mut self) -> Result<AttributeDefault, ParseError> { + if self.looking_at(b"#REQUIRED") { + self.expect_str(b"#REQUIRED")?; + Ok(AttributeDefault::Required) + } else if self.looking_at(b"#IMPLIED") { + self.expect_str(b"#IMPLIED")?; + Ok(AttributeDefault::Implied) + } else if self.looking_at(b"#FIXED") { + self.expect_str(b"#FIXED")?; + self.skip_whitespace_required()?; + let value = self.parse_quoted_value()?; + self.validate_default_value(&value)?; + Ok(AttributeDefault::Fixed(value)) + } else { + let value = self.parse_quoted_value()?; + self.validate_default_value(&value)?; + Ok(AttributeDefault::Default(value)) + } + } + + // --- ENTITY declaration --- + // See XML 1.0 §4.2: [70] EntityDecl + + #[allow(clippy::too_many_lines)] + fn parse_entity_decl(&mut self) -> Result<(), ParseError> { + self.expect_str(b"<!ENTITY")?; + self.skip_whitespace_required()?; + + // Parameter entities (% name) + if self.peek() == Some(b'%') { + self.advance(1); + self.skip_whitespace_required()?; + let pe_name = self.parse_name()?; + // Namespaces in XML 1.0: entity names must be NCNames (no colons). + if pe_name.contains(':') { + return Err(self.fatal(format!("entity name '{pe_name}' must not contain a colon"))); + } + self.skip_whitespace_required()?; + + let pe_kind = if self.peek() == Some(b'"') || self.peek() == Some(b'\'') { + // Internal PE — parse and validate the value + let value = self.parse_quoted_value()?; + self.validate_entity_value(&value, true)?; + Some(EntityKind::Internal(value)) + } else if self.looking_at(b"SYSTEM") { + // External PE — parse external ID + self.expect_str(b"SYSTEM")?; + self.skip_whitespace_required()?; + let system_id = self.parse_quoted_value()?; + Some(EntityKind::External { + system_id, + public_id: None, + }) + } else if self.looking_at(b"PUBLIC") { + self.expect_str(b"PUBLIC")?; + self.skip_whitespace_required()?; + let public_id = self.parse_quoted_value()?; + self.validate_public_id(&public_id)?; + self.skip_whitespace_required()?; + let system_id = self.parse_quoted_value()?; + Some(EntityKind::External { + system_id, + public_id: Some(public_id), + }) + } else { + return Err(self.fatal("expected entity value or external ID")); + }; + + self.skip_whitespace(); + // Reject NDATA on parameter entities (XML 1.0 §4.2.2) + if self.looking_at(b"NDATA") { + return Err(self.fatal("NDATA annotation is not allowed on parameter entities")); + } + self.expect_byte(b'>')?; + + // Store PE declaration (first declaration wins per XML 1.0 §4.2) + if let Some(kind) = pe_kind { + self.dtd + .param_entities + .entry(pe_name) + .or_insert(EntityDecl { + name: String::new(), + kind, + }); + } + return Ok(()); + } + + let name = self.parse_name()?; + // Namespaces in XML 1.0: entity names must be NCNames (no colons). + if name.contains(':') { + return Err(self.fatal(format!("entity name '{name}' must not contain a colon"))); + } + self.skip_whitespace_required()?; + + let is_parameter_entity = false; + let kind = if self.peek() == Some(b'"') || self.peek() == Some(b'\'') { + // Internal entity + let value = self.parse_quoted_value()?; + self.validate_entity_value(&value, is_parameter_entity)?; + EntityKind::Internal(value) + } else if self.looking_at(b"SYSTEM") { + self.expect_str(b"SYSTEM")?; + self.skip_whitespace_required()?; + let system_id = self.parse_quoted_value()?; + EntityKind::External { + system_id, + public_id: None, + } + } else if self.looking_at(b"PUBLIC") { + self.expect_str(b"PUBLIC")?; + self.skip_whitespace_required()?; + let public_id = self.parse_quoted_value()?; + self.validate_public_id(&public_id)?; + self.skip_whitespace_required()?; + let system_id = self.parse_quoted_value()?; + EntityKind::External { + system_id, + public_id: Some(public_id), + } + } else { + return Err(self.fatal("expected entity value or external ID")); + }; + + let had_ws = self.skip_whitespace(); + + // Handle optional NDATA for unparsed external entities (XML 1.0 §4.2.2) + if self.looking_at(b"NDATA") { + // NDATA is only allowed on external entities + if matches!(kind, EntityKind::Internal(_)) { + return Err(self.fatal("NDATA annotation is not allowed on internal entities")); + } + // Whitespace is required before NDATA (XML 1.0 §4.2.2) + if !had_ws { + return Err(self.fatal("whitespace required before NDATA")); + } + self.expect_str(b"NDATA")?; + self.skip_whitespace_required()?; + let _notation_name = self.parse_name()?; + self.skip_whitespace(); + } + + self.expect_byte(b'>')?; + + // Per XML 1.0 §4.2, the first entity declaration is binding; + // subsequent declarations of the same entity are ignored. + let decl = EntityDecl { + name: name.clone(), + kind, + }; + self.dtd + .declarations + .push(DtdDeclaration::Entity(decl.clone())); + self.dtd.entities.entry(name).or_insert(decl); + Ok(()) + } + + // --- NOTATION declaration --- + // See XML 1.0 §4.7: [82] NotationDecl + + fn parse_notation_decl(&mut self) -> Result<(), ParseError> { + self.expect_str(b"<!NOTATION")?; + self.skip_whitespace_required()?; + let name = self.parse_name()?; + // Namespaces in XML 1.0: notation names must be NCNames (no colons). + if name.contains(':') { + return Err(self.fatal(format!("notation name '{name}' must not contain a colon"))); + } + self.skip_whitespace_required()?; + + let (system_id, public_id) = if self.looking_at(b"SYSTEM") { + self.expect_str(b"SYSTEM")?; + self.skip_whitespace_required()?; + let sid = self.parse_quoted_value()?; + (Some(sid), None) + } else if self.looking_at(b"PUBLIC") { + self.expect_str(b"PUBLIC")?; + self.skip_whitespace_required()?; + let pid = self.parse_quoted_value()?; + self.validate_public_id(&pid)?; + // System ID is optional for notations with PUBLIC + self.skip_whitespace(); + let sid = if self.peek() == Some(b'"') || self.peek() == Some(b'\'') { + Some(self.parse_quoted_value()?) + } else { + None + }; + (sid, Some(pid)) + } else { + return Err(self.fatal("expected SYSTEM or PUBLIC in NOTATION declaration")); + }; + + self.skip_whitespace(); + self.expect_byte(b'>')?; + + let decl = NotationDecl { + name: name.clone(), + system_id, + public_id, + }; + self.dtd + .declarations + .push(DtdDeclaration::Notation(decl.clone())); + self.dtd.notations.insert(name, decl); + Ok(()) + } + + // --- Skip helpers --- + + /// Parses a comment and stores it as a `DtdDeclaration::Comment`. + fn parse_comment_decl(&mut self) -> Result<(), ParseError> { + self.expect_str(b"<!--")?; + let start = self.pos; + loop { + if self.at_end() { + return Err(self.fatal("unexpected end of input in comment")); + } + if self.looking_at(b"-->") { + let text = std::str::from_utf8(&self.input[start..self.pos]) + .unwrap_or("") + .to_string(); + self.advance(3); + self.dtd.declarations.push(DtdDeclaration::Comment(text)); + return Ok(()); + } + self.advance(1); + } + } + + /// Parses a processing instruction and stores it as a `DtdDeclaration::Pi`. + fn parse_pi_decl(&mut self) -> Result<(), ParseError> { + self.expect_str(b"<?")?; + + // Parse and validate the PI target name (XML 1.0 §2.6) + let target = self.parse_name()?; + + // Reject <?xml ...?> inside DTD (XML 1.0 §2.8) + if target.eq_ignore_ascii_case("xml") { + return Err(self.fatal("XML declaration is not allowed inside DTD")); + } + + // If we're immediately at ?>, no data — that's fine + if self.looking_at(b"?>") { + self.advance(2); + self.dtd.declarations.push(DtdDeclaration::Pi(target, None)); + return Ok(()); + } + + // If there's data, whitespace is required between target and data + let is_ws = self + .peek() + .is_some_and(|b| b == b' ' || b == b'\t' || b == b'\r' || b == b'\n'); + if !is_ws { + return Err(self.fatal("space required between PI target and data")); + } + + let start = self.pos; + loop { + if self.at_end() { + return Err(self.fatal("unexpected end of input in processing instruction")); + } + if self.looking_at(b"?>") { + let data = std::str::from_utf8(&self.input[start..self.pos]) + .unwrap_or("") + .trim() + .to_string(); + self.advance(2); + let data = if data.is_empty() { None } else { Some(data) }; + self.dtd.declarations.push(DtdDeclaration::Pi(target, data)); + return Ok(()); + } + self.advance(1); + } + } + + fn skip_pe_reference(&mut self) -> Result<(), ParseError> { + self.expect_byte(b'%')?; + // Read the name + let _name = self.parse_name()?; + self.expect_byte(b';')?; + Ok(()) + } + + // --- Name / token parsing --- + + fn parse_name(&mut self) -> Result<String, ParseError> { + if self.pos >= self.input.len() { + return Err(self.fatal("expected name, found end of input")); + } + + let start = self.pos; + let first = self.input[self.pos]; + + // ASCII fast path + if is_ascii_name_start(first) { + self.pos += 1; + self.column += 1; + while self.pos < self.input.len() && is_ascii_name_char(self.input[self.pos]) { + self.pos += 1; + self.column += 1; + } + if self.pos >= self.input.len() || self.input[self.pos] < 0x80 { + let name = std::str::from_utf8(&self.input[start..self.pos]) + .map_err(|_| self.fatal("invalid UTF-8 in name"))?; + return Ok(name.to_string()); + } + // Fall through to slow path for non-ASCII continuation + } else { + let ch = self + .peek_char() + .ok_or_else(|| self.fatal("expected name"))?; + if !is_name_start_char(ch) { + return Err(self.fatal(format!("invalid name start character: '{ch}'"))); + } + self.advance_char(ch); + } + + while let Some(ch) = self.peek_char() { + if is_name_char(ch) { + self.advance_char(ch); + } else { + break; + } + } + + let name = std::str::from_utf8(&self.input[start..self.pos]) + .map_err(|_| self.fatal("invalid UTF-8 in name"))?; + Ok(name.to_string()) + } + + fn parse_nmtoken(&mut self) -> Result<String, ParseError> { + if self.pos >= self.input.len() { + return Err(self.fatal("expected NMTOKEN, found end of input")); + } + + let start = self.pos; + let first = self.input[self.pos]; + + // ASCII fast path + if is_ascii_name_char(first) { + self.pos += 1; + self.column += 1; + while self.pos < self.input.len() && is_ascii_name_char(self.input[self.pos]) { + self.pos += 1; + self.column += 1; + } + if self.pos >= self.input.len() || self.input[self.pos] < 0x80 { + let token = std::str::from_utf8(&self.input[start..self.pos]) + .map_err(|_| self.fatal("invalid UTF-8 in NMTOKEN"))?; + return Ok(token.to_string()); + } + // Fall through to slow path + } else { + let ch = self + .peek_char() + .ok_or_else(|| self.fatal("expected NMTOKEN"))?; + if !is_name_char(ch) { + return Err(self.fatal(format!("invalid NMTOKEN character: '{ch}'"))); + } + self.advance_char(ch); + } + + while let Some(ch) = self.peek_char() { + if is_name_char(ch) { + self.advance_char(ch); + } else { + break; + } + } + + let token = std::str::from_utf8(&self.input[start..self.pos]) + .map_err(|_| self.fatal("invalid UTF-8 in NMTOKEN"))?; + Ok(token.to_string()) + } + + /// Validates an entity value per XML 1.0 §4.3.2 `EntityValue` production. + /// + /// Checks that `&` is only used in valid entity/character references, + /// and that `%` is not present in general entity values. + #[allow(clippy::too_many_lines)] + fn validate_entity_value( + &self, + value: &str, + is_parameter_entity: bool, + ) -> Result<(), ParseError> { + // First validate all characters are valid XML chars. + for c in value.chars() { + if !crate::parser::input::is_xml_char(c) { + return Err(self.fatal(format!( + "invalid XML character U+{:04X} in entity value", + c as u32 + ))); + } + } + + // Text declarations (<?xml ...?>) are forbidden in internal + // entities (XML 1.0 §4.3.1). They may only appear at the start + // of external parsed entities. + if value.starts_with("<?xml") { + let after = value.as_bytes().get(5).copied(); + if after.map_or(true, |b| b == b' ' || b == b'\t' || b == b'?') { + return Err(self.fatal("text declaration is not allowed in internal entity value")); + } + } + + let bytes = value.as_bytes(); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'&' => { + // Must be a valid reference: &name; or &#N; or &#xH; + i += 1; + if i >= bytes.len() { + return Err(self.fatal("incomplete reference in entity value: '&' at end")); + } + if bytes[i] == b'#' { + // Character reference — parse and validate + i += 1; + let char_val = if i < bytes.len() && bytes[i] == b'x' { + i += 1; + let hex_start = i; + if i >= bytes.len() || !bytes[i].is_ascii_hexdigit() { + return Err( + self.fatal("malformed character reference in entity value") + ); + } + while i < bytes.len() && bytes[i].is_ascii_hexdigit() { + i += 1; + } + let hex_str = std::str::from_utf8(&bytes[hex_start..i]).unwrap_or(""); + u32::from_str_radix(hex_str, 16).unwrap_or(0) + } else { + let dec_start = i; + if i >= bytes.len() || !bytes[i].is_ascii_digit() { + return Err( + self.fatal("malformed character reference in entity value") + ); + } + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + let dec_str = std::str::from_utf8(&bytes[dec_start..i]).unwrap_or(""); + dec_str.parse::<u32>().unwrap_or(0) + }; + if i >= bytes.len() || bytes[i] != b';' { + return Err( + self.fatal("incomplete character reference in entity value") + ); + } + i += 1; + // Validate the referenced character is a valid XML char + if let Some(c) = char::from_u32(char_val) { + if !crate::parser::input::is_xml_char(c) { + return Err(self.fatal(format!( + "character reference &#x{char_val:X}; refers to invalid XML character" + ))); + } + } else { + return Err(self.fatal(format!( + "character reference value {char_val} is not a valid Unicode code point" + ))); + } + } else { + // Entity reference — must be Name followed by ';' + let start = i; + while i < bytes.len() + && bytes[i] != b';' + && bytes[i] != b'&' + && !bytes[i].is_ascii_whitespace() + { + i += 1; + } + if i == start || i >= bytes.len() || bytes[i] != b';' { + return Err(self.fatal("malformed entity reference in entity value")); + } + // Validate the entity name starts with a NameStartChar + let name_str = std::str::from_utf8(&bytes[start..i]).unwrap_or(""); + if let Some(first_char) = name_str.chars().next() { + if !is_name_start_char(first_char) { + return Err(self.fatal(format!( + "entity reference name must start with a letter or underscore, found '{first_char}'" + ))); + } + } + i += 1; + } + } + b'%' if !is_parameter_entity => { + // '%' is not allowed in general entity values (XML 1.0 §4.3.2) + return Err(self.fatal("'%' not allowed in general entity value")); + } + b'%' if is_parameter_entity => { + // WFC: PEs in Internal Subset — PE references MUST NOT + // occur within markup declarations in the internal subset + // (XML 1.0 §2.8). + i += 1; + if i < bytes.len() { + let first = bytes[i]; + if first.is_ascii_alphabetic() || first == b'_' || first == b':' { + return Err(self.fatal( + "parameter entity reference not allowed within \ + markup declaration in internal subset", + )); + } + } + } + _ => { + i += 1; + } + } + } + Ok(()) + } + + /// Validates an attribute default value per XML 1.0 §3.3.2. + /// + /// Checks that entity references within the default value refer to + /// entities that have already been declared (WFC: Entity Declared). + /// Also rejects `<` in default values (WFC: No `<` in Attribute Values). + fn validate_default_value(&self, value: &str) -> Result<(), ParseError> { + let bytes = value.as_bytes(); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'<' => { + return Err(self.fatal("'<' not allowed in attribute default value")); + } + b'&' => { + i += 1; + if i < bytes.len() && bytes[i] == b'#' { + // Character reference — skip over it + i += 1; + while i < bytes.len() && bytes[i] != b';' { + i += 1; + } + if i < bytes.len() { + i += 1; + } + } else { + // Entity reference — extract name and check declaration + let start = i; + while i < bytes.len() && bytes[i] != b';' { + i += 1; + } + if i > start && i < bytes.len() { + let name = std::str::from_utf8(&bytes[start..i]).unwrap_or(""); + // Built-in entities are always available + let is_builtin = matches!(name, "amp" | "lt" | "gt" | "apos" | "quot"); + if !is_builtin && !self.dtd.entities.contains_key(name) { + return Err(self.fatal(format!( + "undeclared entity '{name}' in attribute default value" + ))); + } + } + if i < bytes.len() { + i += 1; + } + } + } + _ => { + i += 1; + } + } + } + Ok(()) + } + + /// Validates that a public ID string contains only valid `PubidChar`s + /// per XML 1.0 §2.3 `[13]`. + fn validate_public_id(&self, pid: &str) -> Result<(), ParseError> { + for c in pid.chars() { + let valid = matches!(c, + ' ' | '\r' | '\n' | + 'a'..='z' | 'A'..='Z' | '0'..='9' | + '-' | '\'' | '(' | ')' | '+' | ',' | '.' | '/' | ':' | + '=' | '?' | ';' | '!' | '*' | '#' | '@' | '$' | '_' | '%' + ); + if !valid { + return Err(self.fatal(format!( + "invalid character in public ID: U+{:04X}", + c as u32 + ))); + } + } + Ok(()) + } + + fn parse_quoted_value(&mut self) -> Result<String, ParseError> { + let quote = self.next_byte()?; + if quote != b'"' && quote != b'\'' { + return Err(self.fatal("expected quoted value")); + } + let start = self.pos; + while !self.at_end() && self.peek() != Some(quote) { + self.advance(1); + } + let value = std::str::from_utf8(&self.input[start..self.pos]) + .map_err(|_| self.fatal("invalid UTF-8 in quoted value"))? + .to_string(); + if self.at_end() { + return Err(self.fatal("unexpected end of input in quoted value")); + } + self.advance(1); // consume closing quote + Ok(value) + } + + // --- Low-level input helpers --- + + fn location(&self) -> SourceLocation { + SourceLocation { + line: self.line, + column: self.column, + byte_offset: self.pos, + } + } + + fn at_end(&self) -> bool { + self.pos >= self.input.len() + } + + fn peek(&self) -> Option<u8> { + self.input.get(self.pos).copied() + } + + fn peek_char(&self) -> Option<char> { + if self.pos >= self.input.len() { + return None; + } + let first = self.input[self.pos]; + // Fast path: ASCII + if first < 0x80 { + return Some(first as char); + } + // Slow path: multi-byte UTF-8 — decode only the needed bytes + let len = match first { + 0xC0..=0xDF => 2, + 0xE0..=0xEF => 3, + 0xF0..=0xF7 => 4, + _ => return None, + }; + let remaining = &self.input[self.pos..]; + if remaining.len() < len { + return None; + } + std::str::from_utf8(&remaining[..len]) + .ok() + .and_then(|s| s.chars().next()) + } + + fn advance(&mut self, count: usize) { + for _ in 0..count { + if self.pos < self.input.len() { + if self.input[self.pos] == b'\n' { + self.line += 1; + self.column = 1; + } else { + self.column += 1; + } + self.pos += 1; + } + } + } + + fn advance_char(&mut self, ch: char) { + let len = ch.len_utf8(); + if ch == '\n' { + self.line += 1; + self.column = 1; + } else { + self.column += 1; + } + self.pos += len; + } + + fn next_byte(&mut self) -> Result<u8, ParseError> { + if self.at_end() { + return Err(self.fatal("unexpected end of input")); + } + let b = self.input[self.pos]; + self.advance(1); + Ok(b) + } + + fn expect_byte(&mut self, expected: u8) -> Result<(), ParseError> { + let b = self.next_byte()?; + if b == expected { + Ok(()) + } else { + Err(self.fatal(format!( + "expected '{}', found '{}'", + expected as char, b as char + ))) + } + } + + fn expect_str(&mut self, expected: &[u8]) -> Result<(), ParseError> { + for &b in expected { + self.expect_byte(b)?; + } + Ok(()) + } + + fn looking_at(&self, s: &[u8]) -> bool { + self.pos + s.len() <= self.input.len() && self.input[self.pos..].starts_with(s) + } + + fn skip_whitespace(&mut self) -> bool { + let start = self.pos; + while let Some(b) = self.peek() { + if b == b' ' || b == b'\t' || b == b'\r' || b == b'\n' { + self.advance(1); + } else { + break; + } + } + self.pos > start + } + + fn skip_whitespace_required(&mut self) -> Result<(), ParseError> { + if !self.skip_whitespace() { + return Err(self.fatal("whitespace required")); + } + Ok(()) + } + + fn fatal(&self, message: impl Into<String>) -> ParseError { + ParseError { + message: message.into(), + location: self.location(), + diagnostics: Vec::new(), + } + } +} + +// --------------------------------------------------------------------------- +// Entity value helper functions (used by DTD parser and XML parser) +// --------------------------------------------------------------------------- + +/// Expands only character references in a string, leaving entity references +/// as-is. Returns the expanded text. +/// +/// Used to compute the replacement text of an internal entity per XML 1.0 +/// §4.5: character references are expanded at declaration time, while +/// entity references are left for expansion at reference time. +pub(crate) fn expand_char_refs_only(value: &str) -> String { + let bytes = value.as_bytes(); + let mut result = String::with_capacity(value.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'&' && i + 1 < bytes.len() && bytes[i + 1] == b'#' { + i += 2; + let char_val = if i < bytes.len() && bytes[i] == b'x' { + i += 1; + let start = i; + while i < bytes.len() && bytes[i].is_ascii_hexdigit() { + i += 1; + } + let hex = std::str::from_utf8(&bytes[start..i]).unwrap_or("0"); + u32::from_str_radix(hex, 16).unwrap_or(0) + } else { + let start = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + let dec = std::str::from_utf8(&bytes[start..i]).unwrap_or("0"); + dec.parse::<u32>().unwrap_or(0) + }; + if i < bytes.len() && bytes[i] == b';' { + i += 1; + } + if let Some(ch) = char::from_u32(char_val) { + result.push(ch); + } + } else { + // Copy one complete UTF-8 character + let ch = value[i..].chars().next().unwrap_or('\u{FFFD}'); + result.push(ch); + i += ch.len_utf8(); + } + } + result +} + +/// Replaces entity references (`&name;`) with spaces, leaving character +/// references (`&#...;`) and other text unchanged. Correctly handles +/// multi-byte UTF-8 characters. +/// +/// Used to sanitize entity replacement text before fragment parsing so +/// that entity references (which are valid `Reference` productions in +/// content) don't cause undeclared-entity errors. +pub(crate) fn replace_entity_refs(text: &str) -> String { + let bytes = text.as_bytes(); + let mut result = String::with_capacity(text.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'&' && i + 1 < bytes.len() && bytes[i + 1] != b'#' { + // Possible entity reference: &name; + let start = i; + i += 1; + if i < bytes.len() + && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'_' || bytes[i] == b':') + { + // Scan to semicolon + while i < bytes.len() && bytes[i] != b';' { + i += 1; + } + if i < bytes.len() && bytes[i] == b';' { + // Complete entity reference — replace with space + result.push(' '); + i += 1; + } else { + // Incomplete — keep original text + result.push_str(&text[start..i]); + } + } else { + // Not a valid entity ref start — keep the '&' + result.push('&'); + } + } else { + // Copy one complete UTF-8 character + let ch = text[i..].chars().next().unwrap_or('\u{FFFD}'); + result.push(ch); + i += ch.len_utf8(); + } + } + result +} + +// --------------------------------------------------------------------------- +// XML Name character classes (shared with parser/xml.rs) +// --------------------------------------------------------------------------- + +fn is_ascii_name_start(b: u8) -> bool { + b.is_ascii_alphabetic() || b == b'_' || b == b':' +} + +fn is_ascii_name_char(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || b == b':' || b == b'-' || b == b'.' +} + +fn is_name_start_char(c: char) -> bool { + matches!(c, + ':' | 'A'..='Z' | '_' | 'a'..='z' | + '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{2FF}' | + '\u{370}'..='\u{37D}' | '\u{37F}'..='\u{1FFF}' | + '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}' | + '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}' | + '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' | + '\u{10000}'..='\u{EFFFF}' + ) +} + +fn is_name_char(c: char) -> bool { + is_name_start_char(c) + || matches!(c, + '-' | '.' | '0'..='9' | '\u{B7}' | + '\u{300}'..='\u{36F}' | '\u{203F}'..='\u{2040}' + ) +} + +// --------------------------------------------------------------------------- +// DTD Validator +// --------------------------------------------------------------------------- + +/// Validates a document against a DTD. +/// +/// Checks that the document conforms to the element declarations, attribute +/// declarations, and other constraints in the DTD. Returns a +/// [`ValidationResult`] with any errors and warnings. +/// +/// # Checks Performed +/// +/// - Root element name matches DOCTYPE declaration +/// - Element content matches declared content models +/// - Required attributes are present +/// - Attribute values match their declared types (ID uniqueness, IDREF targets, +/// enumeration values) +/// - No undeclared elements (when the DTD declares elements) +/// - No undeclared attributes (when the DTD declares attributes for that element) +/// - `#FIXED` attribute values match the declared value +/// +/// # Examples +/// +/// ``` +/// use xmloxide::Document; +/// use xmloxide::validation::dtd::{parse_dtd, validate}; +/// +/// let dtd = parse_dtd("<!ELEMENT root (#PCDATA)>").unwrap(); +/// let mut doc = Document::parse_str("<!DOCTYPE root><root>hello</root>").unwrap(); +/// let result = validate(&mut doc, &dtd); +/// assert!(result.is_valid); +/// ``` +pub fn validate(doc: &mut Document, dtd: &Dtd) -> ValidationResult { + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + let mut id_values: HashSet<String> = HashSet::new(); + let mut idref_values: Vec<String> = Vec::new(); + + // Check root element name against DOCTYPE + check_root_element(doc, dtd, &mut errors); + + // Walk all element nodes and validate + if let Some(root_elem) = doc.root_element() { + validate_element_recursive( + doc, + dtd, + root_elem, + &mut errors, + &mut warnings, + &mut id_values, + &mut idref_values, + ); + } + + // Check that all IDREF values point to existing IDs + for idref in &idref_values { + if !id_values.contains(idref) { + errors.push(ValidationError { + message: format!("IDREF '{idref}' does not match any ID in the document"), + line: None, + column: None, + }); + } + } + + let is_valid = errors.is_empty(); + ValidationResult { + is_valid, + errors, + warnings, + } +} + +/// Checks that the root element name matches the DOCTYPE name. +fn check_root_element(doc: &Document, _dtd: &Dtd, errors: &mut Vec<ValidationError>) { + // Find the DOCTYPE node to get the declared root name + let doctype_name = doc.children(doc.root()).find_map(|id| { + if let NodeKind::DocumentType { ref name, .. } = doc.node(id).kind { + Some(name.clone()) + } else { + None + } + }); + + if let Some(ref expected_name) = doctype_name { + if let Some(root_elem) = doc.root_element() { + if let Some(actual_name) = doc.node_name(root_elem) { + if actual_name != expected_name { + errors.push(ValidationError { + message: format!( + "root element '{actual_name}' does not match \ + DOCTYPE name '{expected_name}'" + ), + line: None, + column: None, + }); + } + } + } + } +} + +/// Recursively validates an element and its descendants. +#[allow(clippy::too_many_arguments)] +fn validate_element_recursive( + doc: &mut Document, + dtd: &Dtd, + node_id: NodeId, + errors: &mut Vec<ValidationError>, + warnings: &mut Vec<ValidationError>, + id_values: &mut HashSet<String>, + idref_values: &mut Vec<String>, +) { + let elem_name = match doc.node_name(node_id) { + Some(name) => name.to_string(), + None => return, + }; + + // Check if element is declared + let has_element_decls = !dtd.elements.is_empty(); + if has_element_decls && !dtd.elements.contains_key(&elem_name) { + errors.push(ValidationError { + message: format!("element '{elem_name}' is not declared in the DTD"), + line: None, + column: None, + }); + } + + // Check content model + if let Some(elem_decl) = dtd.elements.get(&elem_name) { + validate_content_model(doc, node_id, &elem_name, &elem_decl.content_model, errors); + } + + // Check attributes + validate_attributes( + doc, + dtd, + node_id, + &elem_name, + errors, + warnings, + id_values, + idref_values, + ); + + // Collect child element IDs first to avoid borrow conflicts. Entity + // references are looked through so that entity-supplied elements are + // validated too (XML 1.0 §4.4.3: included replacement text participates + // in validation). + let child_ids: Vec<NodeId> = effective_content_children(doc, node_id) + .into_iter() + .filter(|&child_id| matches!(doc.node(child_id).kind, NodeKind::Element { .. })) + .collect(); + + // Recurse into child elements + for child_id in child_ids { + validate_element_recursive( + doc, + dtd, + child_id, + errors, + warnings, + id_values, + idref_values, + ); + } +} + +/// Collects an element's content children in document order, transparently +/// expanding entity-reference nodes into their parsed replacement children. +/// +/// Per XML 1.0 §4.4.3, replacement text included for an entity reference is +/// part of the document's content and participates in validation, so the +/// content-model checks must see through `EntityRef` nodes. An entity +/// reference without parsed children (e.g. an undeclared entity preserved in +/// tolerant mode) contributes nothing. +fn effective_content_children(doc: &Document, node_id: NodeId) -> Vec<NodeId> { + fn collect(doc: &Document, node_id: NodeId, out: &mut Vec<NodeId>) { + for child in doc.children(node_id) { + if matches!(doc.node(child).kind, NodeKind::EntityRef { .. }) { + collect(doc, child, out); + } else { + out.push(child); + } + } + } + let mut out = Vec::new(); + collect(doc, node_id, &mut out); + out +} + +/// Validates that an element's children match its declared content model. +fn validate_content_model( + doc: &Document, + node_id: NodeId, + elem_name: &str, + model: &ContentModel, + errors: &mut Vec<ValidationError>, +) { + // Entity references are looked through: their parsed replacement + // children are part of the content being validated (XML 1.0 §4.4.3). + let content_children = effective_content_children(doc, node_id); + match model { + ContentModel::Empty => { + // No children at all + let has_content = content_children.iter().any(|&child| { + matches!( + doc.node(child).kind, + NodeKind::Element { .. } | NodeKind::Text { .. } | NodeKind::CData { .. } + ) + }); + if has_content { + errors.push(ValidationError { + message: format!( + "element '{elem_name}' is declared EMPTY \ + but has content" + ), + line: None, + column: None, + }); + } + } + ContentModel::Any => { + // Anything is valid + } + ContentModel::Mixed(allowed_names) => { + // Text is always allowed. Check that element children are in the allowed list. + for &child_id in &content_children { + if let NodeKind::Element { ref name, .. } = doc.node(child_id).kind { + if !allowed_names.contains(name) { + errors.push(ValidationError { + message: format!( + "element '{name}' is not allowed in mixed content \ + of '{elem_name}' (allowed: #PCDATA{})", + if allowed_names.is_empty() { + String::new() + } else { + format!("|{}", allowed_names.join("|")) + } + ), + line: None, + column: None, + }); + } + } + } + } + ContentModel::Children(spec) => { + // Collect element child names (ignore text, comments, PIs) + let child_names: Vec<String> = content_children + .iter() + .filter_map(|&child_id| { + if let NodeKind::Element { ref name, .. } = doc.node(child_id).kind { + Some(name.clone()) + } else { + None + } + }) + .collect(); + + // Check for text content in element-only content model + let has_text = content_children.iter().any(|&child_id| { + if let NodeKind::Text { ref content } = doc.node(child_id).kind { + !content.trim().is_empty() + } else { + matches!(doc.node(child_id).kind, NodeKind::CData { .. }) + } + }); + + if has_text { + errors.push(ValidationError { + message: format!( + "element '{elem_name}' has element-only content model \ + but contains text" + ), + line: None, + column: None, + }); + } + + // Match the sequence of child element names against the content spec + let consumed = match_content_spec(spec, &child_names, 0); + match consumed { + Some(n) if n == child_names.len() => { + // Perfect match + } + _ => { + errors.push(ValidationError { + message: format!( + "element '{elem_name}' content does not match \ + declared content model {model}; \ + found children: [{}]", + child_names.join(", ") + ), + line: None, + column: None, + }); + } + } + } + } +} + +/// Matches a content spec against a slice of element names starting at `pos`. +/// +/// Returns `Some(count)` if the spec matches, consuming `count` names from +/// position `pos`. Returns `None` if no match is possible. +fn match_content_spec(spec: &ContentSpec, names: &[String], pos: usize) -> Option<usize> { + match &spec.kind { + ContentSpecKind::Name(expected) => match_with_occurrence( + |all_names, p| { + if p < all_names.len() && all_names[p] == *expected { + Some(1) + } else { + None + } + }, + names, + pos, + spec.occurrence, + ), + ContentSpecKind::Seq(items) => match_with_occurrence( + |all_names, p| { + let mut current = p; + for item in items { + match match_content_spec(item, all_names, current) { + Some(consumed) => current += consumed, + None => return None, + } + } + Some(current - p) + }, + names, + pos, + spec.occurrence, + ), + ContentSpecKind::Choice(items) => match_with_occurrence( + |all_names, p| { + for item in items { + if let Some(consumed) = match_content_spec(item, all_names, p) { + return Some(consumed); + } + } + None + }, + names, + pos, + spec.occurrence, + ), + } +} + +/// Applies occurrence matching around a base matcher function. +/// +/// The `base_match` function attempts a single match at a given position, +/// returning `Some(consumed)` on success. +fn match_with_occurrence( + base_match: impl Fn(&[String], usize) -> Option<usize>, + names: &[String], + pos: usize, + occurrence: Occurrence, +) -> Option<usize> { + match occurrence { + Occurrence::Once => base_match(names, pos), + Occurrence::Optional => { + // Try matching once; if it fails, succeed consuming 0 + Some(base_match(names, pos).unwrap_or(0)) + } + Occurrence::ZeroOrMore | Occurrence::OneOrMore => { + let mut total = 0; + loop { + match base_match(names, pos + total) { + Some(0) | None => break, // zero-width or no match + Some(n) => total += n, + } + } + // OneOrMore requires at least one match + if occurrence == Occurrence::OneOrMore && total == 0 { + None + } else { + Some(total) + } + } + } +} + +/// Validates attributes for an element against DTD attribute declarations. +#[allow(clippy::too_many_arguments)] +fn validate_attributes( + doc: &mut Document, + dtd: &Dtd, + node_id: NodeId, + elem_name: &str, + errors: &mut Vec<ValidationError>, + _warnings: &mut Vec<ValidationError>, + id_values: &mut HashSet<String>, + idref_values: &mut Vec<String>, +) { + let attr_decls = dtd.attributes.get(elem_name); + let actual_attrs = doc.attributes(node_id).to_vec(); + + if let Some(decls) = attr_decls { + // Check each declared attribute + for decl in decls { + let actual = actual_attrs.iter().find(|a| a.name == decl.attribute_name); + + match (&decl.default, actual) { + (AttributeDefault::Required, None) => { + errors.push(ValidationError { + message: format!( + "required attribute '{}' missing on element '{elem_name}'", + decl.attribute_name + ), + line: None, + column: None, + }); + } + (AttributeDefault::Fixed(fixed_val), Some(attr)) if attr.value != *fixed_val => { + errors.push(ValidationError { + message: format!( + "attribute '{}' on element '{elem_name}' must have \ + fixed value '{fixed_val}', found '{}'", + decl.attribute_name, attr.value + ), + line: None, + column: None, + }); + } + _ => {} + } + + // Type checking for present attributes + if let Some(attr) = actual { + validate_attribute_type( + doc, + node_id, + &attr.value, + &decl.attribute_type, + &decl.attribute_name, + elem_name, + errors, + id_values, + idref_values, + ); + } + } + + // Check for undeclared attributes (skip xmlns-related attributes) + for attr in &actual_attrs { + if attr.name == "xmlns" || attr.prefix.as_deref() == Some("xmlns") { + continue; + } + let is_declared = decls.iter().any(|d| d.attribute_name == attr.name); + if !is_declared { + errors.push(ValidationError { + message: format!( + "attribute '{}' on element '{elem_name}' is not declared in the DTD", + attr.name + ), + line: None, + column: None, + }); + } + } + } +} + +/// Validates an attribute value against its declared type. +#[allow(clippy::too_many_arguments)] +fn validate_attribute_type( + doc: &mut Document, + node_id: NodeId, + value: &str, + attr_type: &AttributeType, + attr_name: &str, + elem_name: &str, + errors: &mut Vec<ValidationError>, + id_values: &mut HashSet<String>, + idref_values: &mut Vec<String>, +) { + match attr_type { + AttributeType::CData => { + // Any string is valid CDATA + } + AttributeType::Id => { + validate_id_value(doc, node_id, value, attr_name, elem_name, errors, id_values); + } + AttributeType::IdRef => { + validate_idref_value(value, attr_name, elem_name, errors, idref_values); + } + AttributeType::IdRefs => { + validate_idrefs_value(value, attr_name, elem_name, errors, idref_values); + } + AttributeType::NmToken => { + validate_nmtoken_value(value, attr_name, elem_name, errors); + } + AttributeType::NmTokens => { + validate_nmtokens_value(value, attr_name, elem_name, errors); + } + AttributeType::Enumeration(values) | AttributeType::Notation(values) => { + validate_enumeration_value(value, values, attr_name, elem_name, errors); + } + AttributeType::Entity | AttributeType::Entities => { + validate_entity_value(value, attr_type, attr_name, elem_name, errors); + } + } +} + +/// Validates an ID attribute value: must be a valid XML Name and unique. +/// +/// On success, registers the ID in the document's `id_map` so it can be +/// looked up via [`Document::element_by_id`] and the `XPath` `id()` function. +fn validate_id_value( + doc: &mut Document, + node_id: NodeId, + value: &str, + attr_name: &str, + elem_name: &str, + errors: &mut Vec<ValidationError>, + id_values: &mut HashSet<String>, +) { + if !is_valid_name(value) { + errors.push(ValidationError { + message: format!( + "attribute '{attr_name}' on element '{elem_name}' \ + has invalid ID value '{value}' (not a valid XML Name)" + ), + line: None, + column: None, + }); + } else if !id_values.insert(value.to_string()) { + errors.push(ValidationError { + message: format!( + "duplicate ID value '{value}' on attribute '{attr_name}' \ + of element '{elem_name}'" + ), + line: None, + column: None, + }); + } else { + doc.set_id(value, node_id); + } +} + +/// Validates an IDREF attribute value. +fn validate_idref_value( + value: &str, + attr_name: &str, + elem_name: &str, + errors: &mut Vec<ValidationError>, + idref_values: &mut Vec<String>, +) { + if is_valid_name(value) { + idref_values.push(value.to_string()); + } else { + errors.push(ValidationError { + message: format!( + "attribute '{attr_name}' on element '{elem_name}' \ + has invalid IDREF value '{value}'" + ), + line: None, + column: None, + }); + } +} + +/// Validates an IDREFS attribute value (space-separated list). +fn validate_idrefs_value( + value: &str, + attr_name: &str, + elem_name: &str, + errors: &mut Vec<ValidationError>, + idref_values: &mut Vec<String>, +) { + for token in value.split_whitespace() { + if is_valid_name(token) { + idref_values.push(token.to_string()); + } else { + errors.push(ValidationError { + message: format!( + "attribute '{attr_name}' on element '{elem_name}' \ + has invalid IDREFS token '{token}'" + ), + line: None, + column: None, + }); + } + } +} + +/// Validates a NMTOKEN attribute value. +fn validate_nmtoken_value( + value: &str, + attr_name: &str, + elem_name: &str, + errors: &mut Vec<ValidationError>, +) { + if !is_valid_nmtoken(value) { + errors.push(ValidationError { + message: format!( + "attribute '{attr_name}' on element '{elem_name}' \ + has invalid NMTOKEN value '{value}'" + ), + line: None, + column: None, + }); + } +} + +/// Validates a NMTOKENS attribute value (space-separated list). +fn validate_nmtokens_value( + value: &str, + attr_name: &str, + elem_name: &str, + errors: &mut Vec<ValidationError>, +) { + for token in value.split_whitespace() { + if !is_valid_nmtoken(token) { + errors.push(ValidationError { + message: format!( + "attribute '{attr_name}' on element '{elem_name}' \ + has invalid NMTOKENS token '{token}'" + ), + line: None, + column: None, + }); + } + } +} + +/// Validates an enumeration or notation attribute value. +fn validate_enumeration_value( + value: &str, + allowed: &[String], + attr_name: &str, + elem_name: &str, + errors: &mut Vec<ValidationError>, +) { + if !allowed.contains(&value.to_string()) { + errors.push(ValidationError { + message: format!( + "attribute '{attr_name}' on element '{elem_name}' \ + has value '{value}' which is not in the allowed \ + values ({})", + allowed.join("|") + ), + line: None, + column: None, + }); + } +} + +/// Validates an ENTITY or ENTITIES attribute value. +fn validate_entity_value( + value: &str, + attr_type: &AttributeType, + attr_name: &str, + elem_name: &str, + errors: &mut Vec<ValidationError>, +) { + // Entity/Entities validation would require checking against + // declared unparsed entities. For now we just check Name validity. + let tokens: Vec<&str> = if matches!(attr_type, AttributeType::Entities) { + value.split_whitespace().collect() + } else { + vec![value] + }; + for token in tokens { + if !is_valid_name(token) { + errors.push(ValidationError { + message: format!( + "attribute '{attr_name}' on element '{elem_name}' \ + has invalid ENTITY/ENTITIES value '{token}'" + ), + line: None, + column: None, + }); + } + } +} + +/// Checks if a string is a valid XML Name. +fn is_valid_name(s: &str) -> bool { + let mut chars = s.chars(); + match chars.next() { + Some(first) if is_name_start_char(first) => chars.all(is_name_char), + _ => false, + } +} + +/// Checks if a string is a valid NMTOKEN. +fn is_valid_nmtoken(s: &str) -> bool { + !s.is_empty() && s.chars().all(is_name_char) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + // --- DTD Parsing Tests --- + + #[test] + fn test_parse_element_empty() { + let dtd = parse_dtd("<!ELEMENT br EMPTY>").unwrap(); + let decl = dtd.elements.get("br").unwrap(); + assert_eq!(decl.content_model, ContentModel::Empty); + } + + #[test] + fn test_parse_element_any() { + let dtd = parse_dtd("<!ELEMENT container ANY>").unwrap(); + let decl = dtd.elements.get("container").unwrap(); + assert_eq!(decl.content_model, ContentModel::Any); + } + + #[test] + fn test_parse_element_pcdata() { + let dtd = parse_dtd("<!ELEMENT title (#PCDATA)>").unwrap(); + let decl = dtd.elements.get("title").unwrap(); + assert_eq!(decl.content_model, ContentModel::Mixed(vec![])); + } + + #[test] + fn test_parse_element_mixed_content() { + let dtd = parse_dtd("<!ELEMENT p (#PCDATA|em|strong)*>").unwrap(); + let decl = dtd.elements.get("p").unwrap(); + assert_eq!( + decl.content_model, + ContentModel::Mixed(vec!["em".to_string(), "strong".to_string()]) + ); + } + + #[test] + fn test_parse_element_sequence() { + let dtd = parse_dtd("<!ELEMENT book (title,author,year)>").unwrap(); + let decl = dtd.elements.get("book").unwrap(); + match &decl.content_model { + ContentModel::Children(spec) => { + assert_eq!(spec.occurrence, Occurrence::Once); + match &spec.kind { + ContentSpecKind::Seq(items) => { + assert_eq!(items.len(), 3); + assert_eq!(items[0].kind, ContentSpecKind::Name("title".to_string())); + assert_eq!(items[1].kind, ContentSpecKind::Name("author".to_string())); + assert_eq!(items[2].kind, ContentSpecKind::Name("year".to_string())); + } + other => panic!("expected Seq, got {other:?}"), + } + } + other => panic!("expected Children, got {other:?}"), + } + } + + #[test] + fn test_parse_element_choice() { + let dtd = parse_dtd("<!ELEMENT item (a|b|c)>").unwrap(); + let decl = dtd.elements.get("item").unwrap(); + match &decl.content_model { + ContentModel::Children(spec) => match &spec.kind { + ContentSpecKind::Choice(items) => { + assert_eq!(items.len(), 3); + } + other => panic!("expected Choice, got {other:?}"), + }, + other => panic!("expected Children, got {other:?}"), + } + } + + #[test] + fn test_parse_element_occurrence_indicators() { + let dtd = parse_dtd("<!ELEMENT doc (head, body?, appendix*)>").unwrap(); + let decl = dtd.elements.get("doc").unwrap(); + match &decl.content_model { + ContentModel::Children(spec) => match &spec.kind { + ContentSpecKind::Seq(items) => { + assert_eq!(items[0].occurrence, Occurrence::Once); + assert_eq!(items[1].occurrence, Occurrence::Optional); + assert_eq!(items[2].occurrence, Occurrence::ZeroOrMore); + } + other => panic!("expected Seq, got {other:?}"), + }, + other => panic!("expected Children, got {other:?}"), + } + } + + #[test] + fn test_parse_element_nested_groups() { + let dtd = parse_dtd("<!ELEMENT article ((title, author), body)>").unwrap(); + let decl = dtd.elements.get("article").unwrap(); + match &decl.content_model { + ContentModel::Children(spec) => match &spec.kind { + ContentSpecKind::Seq(items) => { + assert_eq!(items.len(), 2); + // First item is a nested sequence (title, author) + match &items[0].kind { + ContentSpecKind::Seq(inner) => { + assert_eq!(inner.len(), 2); + } + other => panic!("expected nested Seq, got {other:?}"), + } + } + other => panic!("expected Seq, got {other:?}"), + }, + other => panic!("expected Children, got {other:?}"), + } + } + + #[test] + fn test_parse_attlist_cdata() { + let dtd = parse_dtd("<!ATTLIST img src CDATA #REQUIRED>").unwrap(); + let decls = dtd.attributes.get("img").unwrap(); + assert_eq!(decls.len(), 1); + assert_eq!(decls[0].attribute_name, "src"); + assert_eq!(decls[0].attribute_type, AttributeType::CData); + assert_eq!(decls[0].default, AttributeDefault::Required); + } + + #[test] + fn test_parse_attlist_id() { + let dtd = parse_dtd("<!ATTLIST div id ID #IMPLIED>").unwrap(); + let decls = dtd.attributes.get("div").unwrap(); + assert_eq!(decls[0].attribute_type, AttributeType::Id); + assert_eq!(decls[0].default, AttributeDefault::Implied); + } + + #[test] + fn test_parse_attlist_enumeration() { + let dtd = parse_dtd("<!ATTLIST input type (text|password|submit) \"text\">").unwrap(); + let decls = dtd.attributes.get("input").unwrap(); + assert_eq!( + decls[0].attribute_type, + AttributeType::Enumeration(vec![ + "text".to_string(), + "password".to_string(), + "submit".to_string() + ]) + ); + assert_eq!( + decls[0].default, + AttributeDefault::Default("text".to_string()) + ); + } + + #[test] + fn test_parse_attlist_fixed() { + let dtd = parse_dtd("<!ATTLIST doc version CDATA #FIXED \"1.0\">").unwrap(); + let decls = dtd.attributes.get("doc").unwrap(); + assert_eq!(decls[0].default, AttributeDefault::Fixed("1.0".to_string())); + } + + #[test] + fn test_parse_attlist_multiple_attrs() { + let dtd = + parse_dtd("<!ATTLIST person\n name CDATA #REQUIRED\n age NMTOKEN #IMPLIED>").unwrap(); + let decls = dtd.attributes.get("person").unwrap(); + assert_eq!(decls.len(), 2); + assert_eq!(decls[0].attribute_name, "name"); + assert_eq!(decls[1].attribute_name, "age"); + assert_eq!(decls[1].attribute_type, AttributeType::NmToken); + } + + #[test] + fn test_parse_entity_internal() { + let dtd = parse_dtd("<!ENTITY copy \"&#169;\">").unwrap(); + let ent = dtd.entities.get("copy").unwrap(); + match &ent.kind { + EntityKind::Internal(value) => assert_eq!(value, "&#169;"), + EntityKind::External { .. } => panic!("expected Internal, got External"), + } + } + + #[test] + fn test_parse_entity_external() { + let dtd = parse_dtd("<!ENTITY chapter SYSTEM \"chapter.xml\">").unwrap(); + let ent = dtd.entities.get("chapter").unwrap(); + match &ent.kind { + EntityKind::External { + system_id, + public_id, + } => { + assert_eq!(system_id, "chapter.xml"); + assert_eq!(*public_id, None); + } + EntityKind::Internal(val) => panic!("expected External, got Internal({val})"), + } + } + + #[test] + fn test_parse_notation() { + let dtd = parse_dtd("<!NOTATION png SYSTEM \"image/png\">").unwrap(); + let notation = dtd.notations.get("png").unwrap(); + assert_eq!(notation.system_id.as_deref(), Some("image/png")); + } + + #[test] + fn test_parse_dtd_with_comments() { + let dtd = parse_dtd( + "<!-- element declarations -->\n\ + <!ELEMENT root (#PCDATA)>\n\ + <!-- end -->", + ) + .unwrap(); + assert!(dtd.elements.contains_key("root")); + } + + #[test] + fn test_parse_dtd_complex() { + let input = "\ + <!ELEMENT doc (head, body)>\n\ + <!ELEMENT head (title)>\n\ + <!ELEMENT title (#PCDATA)>\n\ + <!ELEMENT body (p+)>\n\ + <!ELEMENT p (#PCDATA|em)*>\n\ + <!ELEMENT em (#PCDATA)>\n\ + <!ATTLIST doc version CDATA #FIXED \"1.0\">\n\ + <!ATTLIST p id ID #IMPLIED>\n\ + <!ENTITY copyright \"Copyright 2024\">\n"; + let dtd = parse_dtd(input).unwrap(); + assert_eq!(dtd.elements.len(), 6); + assert!(dtd.attributes.contains_key("doc")); + assert!(dtd.attributes.contains_key("p")); + assert!(dtd.entities.contains_key("copyright")); + } + + // --- Validation Tests --- + + fn make_doc(xml: &str) -> Document { + Document::parse_str(xml).unwrap() + } + + #[test] + fn test_validate_valid_document() { + let dtd = parse_dtd("<!ELEMENT root (#PCDATA)>").unwrap(); + let mut doc = make_doc("<!DOCTYPE root><root>hello</root>"); + let result = validate(&mut doc, &dtd); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_root_name_mismatch() { + let dtd = parse_dtd("<!ELEMENT root (#PCDATA)>").unwrap(); + let mut doc = make_doc("<!DOCTYPE root><other>text</other>"); + let result = validate(&mut doc, &dtd); + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| e.message.contains("root element 'other'") + && e.message.contains("does not match DOCTYPE name 'root'")), + "errors: {:?}", + result.errors + ); + } + + #[test] + fn test_validate_empty_element() { + let dtd = parse_dtd("<!ELEMENT br EMPTY>").unwrap(); + let mut doc = make_doc("<!DOCTYPE br><br/>"); + let result = validate(&mut doc, &dtd); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_empty_element_has_content() { + let dtd = parse_dtd("<!ELEMENT br EMPTY>").unwrap(); + let mut doc = make_doc("<!DOCTYPE br><br>text</br>"); + let result = validate(&mut doc, &dtd); + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| e.message.contains("EMPTY") && e.message.contains("has content")), + "errors: {:?}", + result.errors + ); + } + + #[test] + fn test_validate_any_content() { + let dtd = parse_dtd( + "<!ELEMENT container ANY>\n\ + <!ELEMENT child (#PCDATA)>", + ) + .unwrap(); + let mut doc = make_doc("<!DOCTYPE container><container><child>text</child></container>"); + let result = validate(&mut doc, &dtd); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_sequence_correct() { + let dtd = parse_dtd( + "<!ELEMENT book (title,author)>\n\ + <!ELEMENT title (#PCDATA)>\n\ + <!ELEMENT author (#PCDATA)>", + ) + .unwrap(); + let mut doc = make_doc( + "<!DOCTYPE book>\ + <book><title>XML</title><author>Jon</author></book>", + ); + let result = validate(&mut doc, &dtd); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_sequence_wrong_order() { + let dtd = parse_dtd( + "<!ELEMENT book (title,author)>\n\ + <!ELEMENT title (#PCDATA)>\n\ + <!ELEMENT author (#PCDATA)>", + ) + .unwrap(); + let mut doc = make_doc( + "<!DOCTYPE book>\ + <book><author>Jon</author><title>XML</title></book>", + ); + let result = validate(&mut doc, &dtd); + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| e.message.contains("content does not match")), + "errors: {:?}", + result.errors + ); + } + + #[test] + fn test_validate_required_attribute_missing() { + let dtd = parse_dtd( + "<!ELEMENT img EMPTY>\n\ + <!ATTLIST img src CDATA #REQUIRED>", + ) + .unwrap(); + let mut doc = make_doc("<!DOCTYPE img><img/>"); + let result = validate(&mut doc, &dtd); + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| e.message.contains("required attribute 'src'")), + "errors: {:?}", + result.errors + ); + } + + #[test] + fn test_validate_required_attribute_present() { + let dtd = parse_dtd( + "<!ELEMENT img EMPTY>\n\ + <!ATTLIST img src CDATA #REQUIRED>", + ) + .unwrap(); + let mut doc = make_doc("<!DOCTYPE img><img src=\"photo.jpg\"/>"); + let result = validate(&mut doc, &dtd); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_fixed_attribute_correct() { + let dtd = parse_dtd( + "<!ELEMENT doc (#PCDATA)>\n\ + <!ATTLIST doc version CDATA #FIXED \"1.0\">", + ) + .unwrap(); + let mut doc = make_doc("<!DOCTYPE doc><doc version=\"1.0\">text</doc>"); + let result = validate(&mut doc, &dtd); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_fixed_attribute_wrong_value() { + let dtd = parse_dtd( + "<!ELEMENT doc (#PCDATA)>\n\ + <!ATTLIST doc version CDATA #FIXED \"1.0\">", + ) + .unwrap(); + let mut doc = make_doc("<!DOCTYPE doc><doc version=\"2.0\">text</doc>"); + let result = validate(&mut doc, &dtd); + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| e.message.contains("fixed value '1.0'")), + "errors: {:?}", + result.errors + ); + } + + #[test] + fn test_validate_enumeration_valid() { + let dtd = parse_dtd( + "<!ELEMENT input EMPTY>\n\ + <!ATTLIST input type (text|password) #REQUIRED>", + ) + .unwrap(); + let mut doc = make_doc("<!DOCTYPE input><input type=\"text\"/>"); + let result = validate(&mut doc, &dtd); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_enumeration_invalid() { + let dtd = parse_dtd( + "<!ELEMENT input EMPTY>\n\ + <!ATTLIST input type (text|password) #REQUIRED>", + ) + .unwrap(); + let mut doc = make_doc("<!DOCTYPE input><input type=\"checkbox\"/>"); + let result = validate(&mut doc, &dtd); + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| e.message.contains("not in the allowed values")), + "errors: {:?}", + result.errors + ); + } + + #[test] + fn test_validate_duplicate_id() { + let dtd = parse_dtd( + "<!ELEMENT root (item, item)>\n\ + <!ELEMENT item (#PCDATA)>\n\ + <!ATTLIST item id ID #REQUIRED>", + ) + .unwrap(); + let mut doc = make_doc( + "<!DOCTYPE root>\ + <root>\ + <item id=\"a\">first</item>\ + <item id=\"a\">second</item>\ + </root>", + ); + let result = validate(&mut doc, &dtd); + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| e.message.contains("duplicate ID value 'a'")), + "errors: {:?}", + result.errors + ); + } + + #[test] + fn test_validate_idref_valid() { + let dtd = parse_dtd( + "<!ELEMENT root (item, ref)>\n\ + <!ELEMENT item (#PCDATA)>\n\ + <!ELEMENT ref (#PCDATA)>\n\ + <!ATTLIST item id ID #REQUIRED>\n\ + <!ATTLIST ref target IDREF #REQUIRED>", + ) + .unwrap(); + let mut doc = make_doc( + "<!DOCTYPE root>\ + <root>\ + <item id=\"x\">item</item>\ + <ref target=\"x\">ref</ref>\ + </root>", + ); + let result = validate(&mut doc, &dtd); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_idref_dangling() { + let dtd = parse_dtd( + "<!ELEMENT root (ref)>\n\ + <!ELEMENT ref (#PCDATA)>\n\ + <!ATTLIST ref target IDREF #REQUIRED>", + ) + .unwrap(); + let mut doc = make_doc( + "<!DOCTYPE root>\ + <root><ref target=\"nonexistent\">ref</ref></root>", + ); + let result = validate(&mut doc, &dtd); + assert!(!result.is_valid); + assert!( + result.errors.iter().any(|e| e + .message + .contains("IDREF 'nonexistent' does not match any ID")), + "errors: {:?}", + result.errors + ); + } + + #[test] + fn test_validate_undeclared_element() { + let dtd = parse_dtd("<!ELEMENT root (child)>\n<!ELEMENT child (#PCDATA)>").unwrap(); + let mut doc = make_doc("<!DOCTYPE root><root><unknown/></root>"); + let result = validate(&mut doc, &dtd); + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| e.message.contains("element 'unknown' is not declared")), + "errors: {:?}", + result.errors + ); + } + + #[test] + fn test_validate_undeclared_attribute() { + let dtd = parse_dtd( + "<!ELEMENT root (#PCDATA)>\n\ + <!ATTLIST root id ID #IMPLIED>", + ) + .unwrap(); + let mut doc = make_doc("<!DOCTYPE root><root id=\"x\" bogus=\"y\">text</root>"); + let result = validate(&mut doc, &dtd); + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| e.message.contains("attribute 'bogus'") + && e.message.contains("not declared")), + "errors: {:?}", + result.errors + ); + } + + #[test] + fn test_validate_mixed_content_valid() { + let dtd = parse_dtd( + "<!ELEMENT p (#PCDATA|em|strong)*>\n\ + <!ELEMENT em (#PCDATA)>\n\ + <!ELEMENT strong (#PCDATA)>", + ) + .unwrap(); + let mut doc = make_doc( + "<!DOCTYPE p>\ + <p>Hello <em>world</em> and <strong>friends</strong></p>", + ); + let result = validate(&mut doc, &dtd); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_mixed_content_invalid_child() { + let dtd = parse_dtd( + "<!ELEMENT p (#PCDATA|em)*>\n\ + <!ELEMENT em (#PCDATA)>\n\ + <!ELEMENT b (#PCDATA)>", + ) + .unwrap(); + let mut doc = make_doc( + "<!DOCTYPE p>\ + <p>Hello <b>world</b></p>", + ); + let result = validate(&mut doc, &dtd); + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| e.message.contains("'b' is not allowed in mixed content")), + "errors: {:?}", + result.errors + ); + } + + #[test] + fn test_validate_choice_correct() { + let dtd = parse_dtd( + "<!ELEMENT item (a|b)>\n\ + <!ELEMENT a (#PCDATA)>\n\ + <!ELEMENT b (#PCDATA)>", + ) + .unwrap(); + let mut doc = make_doc("<!DOCTYPE item><item><b>hello</b></item>"); + let result = validate(&mut doc, &dtd); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_one_or_more() { + let dtd = parse_dtd("<!ELEMENT list (item+)>\n<!ELEMENT item (#PCDATA)>").unwrap(); + + // Valid: one item + let mut doc = make_doc("<!DOCTYPE list><list><item>a</item></list>"); + assert!(validate(&mut doc, &dtd).is_valid); + + // Valid: multiple items + let mut doc = make_doc("<!DOCTYPE list><list><item>a</item><item>b</item></list>"); + assert!(validate(&mut doc, &dtd).is_valid); + + // Invalid: zero items + let mut doc = make_doc("<!DOCTYPE list><list></list>"); + assert!(!validate(&mut doc, &dtd).is_valid); + } + + #[test] + fn test_validate_zero_or_more() { + let dtd = parse_dtd("<!ELEMENT list (item*)>\n<!ELEMENT item (#PCDATA)>").unwrap(); + + // Valid: zero items + let mut doc = make_doc("<!DOCTYPE list><list></list>"); + assert!(validate(&mut doc, &dtd).is_valid); + + // Valid: multiple items + let mut doc = make_doc("<!DOCTYPE list><list><item>a</item><item>b</item></list>"); + assert!(validate(&mut doc, &dtd).is_valid); + } + + #[test] + fn test_validate_optional_element() { + let dtd = parse_dtd( + "<!ELEMENT doc (title, subtitle?)>\n\ + <!ELEMENT title (#PCDATA)>\n\ + <!ELEMENT subtitle (#PCDATA)>", + ) + .unwrap(); + + // Valid: with optional + let mut doc = make_doc( + "<!DOCTYPE doc>\ + <doc><title>T</title><subtitle>S</subtitle></doc>", + ); + assert!(validate(&mut doc, &dtd).is_valid); + + // Valid: without optional + let mut doc = make_doc("<!DOCTYPE doc><doc><title>T</title></doc>"); + assert!(validate(&mut doc, &dtd).is_valid); + } + + #[test] + fn test_content_model_display() { + assert_eq!(ContentModel::Empty.to_string(), "EMPTY"); + assert_eq!(ContentModel::Any.to_string(), "ANY"); + assert_eq!(ContentModel::Mixed(vec![]).to_string(), "(#PCDATA)"); + assert_eq!( + ContentModel::Mixed(vec!["a".to_string(), "b".to_string()]).to_string(), + "(#PCDATA|a|b)*" + ); + + let spec = ContentSpec { + kind: ContentSpecKind::Seq(vec![ + ContentSpec { + kind: ContentSpecKind::Name("a".to_string()), + occurrence: Occurrence::Once, + }, + ContentSpec { + kind: ContentSpecKind::Name("b".to_string()), + occurrence: Occurrence::ZeroOrMore, + }, + ]), + occurrence: Occurrence::Once, + }; + assert_eq!(ContentModel::Children(spec).to_string(), "(a , b*)"); + } + + #[test] + fn test_parse_attlist_idref_idrefs() { + let dtd = parse_dtd( + "<!ATTLIST link target IDREF #REQUIRED>\n\ + <!ATTLIST group members IDREFS #REQUIRED>", + ) + .unwrap(); + let link_decls = dtd.attributes.get("link").unwrap(); + assert_eq!(link_decls[0].attribute_type, AttributeType::IdRef); + let group_decls = dtd.attributes.get("group").unwrap(); + assert_eq!(group_decls[0].attribute_type, AttributeType::IdRefs); + } + + #[test] + fn test_validate_element_content_with_text() { + let dtd = parse_dtd("<!ELEMENT book (title)>\n<!ELEMENT title (#PCDATA)>").unwrap(); + let mut doc = make_doc("<!DOCTYPE book><book>stray text<title>T</title></book>"); + let result = validate(&mut doc, &dtd); + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| e.message.contains("element-only content model") + && e.message.contains("contains text")), + "errors: {:?}", + result.errors + ); + } + + #[test] + fn test_parse_entity_public() { + let dtd = parse_dtd("<!ENTITY logo PUBLIC \"-//LOGO//\" \"logo.png\">").unwrap(); + let ent = dtd.entities.get("logo").unwrap(); + match &ent.kind { + EntityKind::External { + system_id, + public_id, + } => { + assert_eq!(system_id, "logo.png"); + assert_eq!(public_id.as_deref(), Some("-//LOGO//")); + } + EntityKind::Internal(val) => panic!("expected External, got Internal({val})"), + } + } + + #[test] + fn test_parse_notation_public() { + let dtd = parse_dtd("<!NOTATION gif PUBLIC \"-//GIF//\">").unwrap(); + let notation = dtd.notations.get("gif").unwrap(); + assert_eq!(notation.public_id.as_deref(), Some("-//GIF//")); + assert_eq!(notation.system_id, None); + } + + #[test] + fn test_parse_parameter_entity_skipped() { + // Parameter entities should be skipped without error + let dtd = parse_dtd( + "<!ENTITY % common \"(#PCDATA)\">\n\ + <!ELEMENT root (#PCDATA)>", + ) + .unwrap(); + assert!(dtd.elements.contains_key("root")); + } + + #[test] + fn test_validate_nmtoken_attribute() { + let dtd = parse_dtd( + "<!ELEMENT root (#PCDATA)>\n\ + <!ATTLIST root token NMTOKEN #REQUIRED>", + ) + .unwrap(); + + // Valid NMTOKEN + let mut doc = make_doc("<!DOCTYPE root><root token=\"abc-123\">text</root>"); + assert!(validate(&mut doc, &dtd).is_valid); + + // Invalid NMTOKEN (spaces not allowed) + let mut doc = make_doc("<!DOCTYPE root><root token=\"abc 123\">text</root>"); + let result = validate(&mut doc, &dtd); + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| e.message.contains("invalid NMTOKEN")), + "errors: {:?}", + result.errors + ); + } + + #[test] + fn test_validate_populates_id_map() { + let dtd = parse_dtd( + "<!ELEMENT root (item*)>\n\ + <!ELEMENT item (#PCDATA)>\n\ + <!ATTLIST item id ID #REQUIRED>", + ) + .unwrap(); + let mut doc = + make_doc(r#"<!DOCTYPE root><root><item id="a">A</item><item id="b">B</item></root>"#); + let result = validate(&mut doc, &dtd); + assert!(result.is_valid, "errors: {:?}", result.errors); + + // The id_map should have been populated + let item_a = doc.element_by_id("a"); + assert!(item_a.is_some(), "expected to find element with id='a'"); + let item_b = doc.element_by_id("b"); + assert!(item_b.is_some(), "expected to find element with id='b'"); + assert_eq!(doc.element_by_id("c"), None); + + // Verify the nodes are the correct elements + assert_eq!(doc.node_name(item_a.unwrap()), Some("item")); + assert_eq!(doc.node_name(item_b.unwrap()), Some("item")); + } + + #[test] + fn test_content_model_deep_nesting_is_bounded() { + // Regression test for CVE-2026-61727 (GHSA-7jmw-29gc-ffx4): a content + // model with deeply nested '(' must return a normal error instead of + // recursing until the stack overflows. + let depth = (MAX_CONTENT_MODEL_DEPTH as usize) + 50; + let mut src = String::from("<!ELEMENT r "); + src.push_str(&"(".repeat(depth)); + src.push('a'); + src.push_str(&")".repeat(depth)); + src.push('>'); + + let result = parse_dtd(&src); + let Err(err) = result else { + panic!("expected deep content model to be rejected, got Ok"); + }; + assert!( + err.message.contains("maximum depth"), + "unexpected error message: {}", + err.message + ); + } + + #[test] + fn test_content_model_shallow_nesting_still_parses() { + // The depth bound must not affect ordinary, shallowly-nested models. + let dtd = parse_dtd("<!ELEMENT a (b,(c|d),e)>").unwrap(); + assert!(dtd.elements.contains_key("a")); + } + + // --- Entity recursion checks (WFC: No Recursion, XML 1.0 §4.1) --- + + #[test] + fn test_entity_recursion_direct_cycle_rejected() { + let err = parse_dtd("<!ENTITY x \"&x;\">").unwrap_err(); + assert!( + err.message.contains("recursive entity reference"), + "unexpected error message: {}", + err.message + ); + } + + #[test] + fn test_entity_recursion_indirect_cycle_rejected() { + let err = + parse_dtd("<!ENTITY a \"&b;\"><!ENTITY b \"&c;\"><!ENTITY c \"&a;\">").unwrap_err(); + assert!( + err.message.contains("recursive entity reference"), + "unexpected error message: {}", + err.message + ); + } + + #[test] + fn test_entity_recursion_cycle_behind_safe_entity_rejected() { + // Regression test for the memoized walk (issue #42): `ok` is acyclic + // and shared by both cycle members. Marking `ok` as proven-safe must + // not mask the a<->b cycle behind it. + let err = parse_dtd( + "<!ENTITY ok \"text\">\ + <!ENTITY a \"&ok;&b;\">\ + <!ENTITY b \"&ok;&a;\">", + ) + .unwrap_err(); + assert!( + err.message.contains("recursive entity reference"), + "unexpected error message: {}", + err.message + ); + } + + #[test] + fn test_entity_recursion_acyclic_diamond_accepted() { + // d is reachable through two paths (a->b->d and a->c->d) but there + // is no cycle; the memoized walk must accept this. + let dtd = parse_dtd( + "<!ENTITY d \"leaf\">\ + <!ENTITY b \"&d;&d;\">\ + <!ENTITY c \"&d;&d;\">\ + <!ENTITY a \"&b;&c;\">", + ) + .unwrap(); + assert_eq!(dtd.entities.len(), 4); + } + + #[test] + fn test_entity_recursion_deep_chain_is_linear() { + // Regression test for issue #42: each level references the previous + // entity 10 times. Without memoization the recursion check walks + // 10^30 paths and never finishes; with it, this parses instantly. + use std::fmt::Write as _; + let mut src = String::from("<!ENTITY a \"AAAA\">"); + for i in 1..=30u32 { + let prev = if i == 1 { + "a".to_string() + } else { + format!("e{}", i - 1) + }; + let refs = format!("&{prev};").repeat(10); + let _ = write!(src, "<!ENTITY e{i} \"{refs}\">"); + } + let dtd = parse_dtd(&src).unwrap(); + assert_eq!(dtd.entities.len(), 31); + } + + #[test] + fn test_pe_recursion_direct_cycle_rejected() { + // %p; encoded as &#37;p; inside the value (XML 1.0 §4.1). + let err = parse_dtd("<!ENTITY % p \"&#37;p;\">").unwrap_err(); + assert!( + err.message.contains("recursive parameter entity reference"), + "unexpected error message: {}", + err.message + ); + } + + #[test] + fn test_pe_recursion_indirect_cycle_rejected() { + let err = parse_dtd("<!ENTITY % p \"&#37;q;\"><!ENTITY % q \"&#37;p;\">").unwrap_err(); + assert!( + err.message.contains("recursive parameter entity reference"), + "unexpected error message: {}", + err.message + ); + } + + // --- Content-model validation through entity references (§4.4.3) --- + + #[test] + fn test_validate_entity_supplied_child_matches_model() { + // The required <x> child arrives via an entity reference; the + // content model must see through the EntityRef node. + let dtd = parse_dtd("<!ELEMENT d (x)><!ELEMENT x (#PCDATA)>").unwrap(); + let mut doc = make_doc("<!DOCTYPE d [<!ENTITY e \"<x>hi</x>\">]><d>&e;</d>"); + let result = validate(&mut doc, &dtd); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_entity_supplied_child_violates_model() { + // An entity-supplied element that violates the content model must + // still be reported. + let dtd = + parse_dtd("<!ELEMENT d (x)><!ELEMENT x (#PCDATA)><!ELEMENT y (#PCDATA)>").unwrap(); + let mut doc = make_doc("<!DOCTYPE d [<!ENTITY e \"<y>hi</y>\">]><d>&e;</d>"); + let result = validate(&mut doc, &dtd); + assert!(!result.is_valid, "expected content-model violation"); + } + + #[test] + fn test_validate_entity_supplied_element_content_checked() { + // Elements inside entity expansions are themselves validated: <x> + // is declared EMPTY but the entity gives it text content. + let dtd = parse_dtd("<!ELEMENT d (x)><!ELEMENT x EMPTY>").unwrap(); + let mut doc = make_doc("<!DOCTYPE d [<!ENTITY e \"<x>hi</x>\">]><d>&e;</d>"); + let result = validate(&mut doc, &dtd); + assert!(!result.is_valid, "expected EMPTY-content violation"); + assert!( + result + .errors + .iter() + .any(|e| e.message.contains("EMPTY") && e.message.contains("has content")), + "errors: {:?}", + result.errors + ); + } + + #[test] + fn test_validate_mixed_content_through_entity() { + let dtd = parse_dtd("<!ELEMENT p (#PCDATA|em)*><!ELEMENT em (#PCDATA)>").unwrap(); + let mut doc = + make_doc("<!DOCTYPE p [<!ENTITY e \"text <em>emph</em>\">]><p>before &e; after</p>"); + let result = validate(&mut doc, &dtd); + assert!(result.is_valid, "errors: {:?}", result.errors); + } +} diff --git a/browser/vendor/xmloxide/src/validation/mod.rs b/browser/vendor/xmloxide/src/validation/mod.rs new file mode 100644 index 000000000..4e59dcafd --- /dev/null +++ b/browser/vendor/xmloxide/src/validation/mod.rs @@ -0,0 +1,147 @@ +//! Document validation framework. +//! +//! This module provides schema validation for XML documents, supporting +//! DTD, `RelaxNG`, XML Schema (XSD), and ISO Schematron. Each validator +//! parses its schema format and checks document conformance, returning a +//! `ValidationResult` with errors and warnings. +//! +//! # Architecture +//! +//! The validation module is organized into: +//! - Common types (`ValidationResult`, `ValidationError`) used across all validators +//! - DTD validation (`dtd` submodule) for XML 1.0 DTD processing +//! - `RelaxNG` validation (`relaxng` submodule) for `RelaxNG` schema validation +//! - XML Schema validation (`xsd` submodule) for XSD 1.0 validation +//! - Schematron validation (`schematron` submodule) for ISO Schematron rule-based validation + +pub mod dtd; +pub mod relaxng; +pub mod schematron; +pub mod xsd; + +use std::fmt; + +/// Result of validating a document against a schema (DTD, `RelaxNG`, XSD, etc.). +/// +/// Contains the overall validity status plus any errors and warnings +/// encountered during validation. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::validation::ValidationResult; +/// +/// let result = ValidationResult { +/// is_valid: true, +/// errors: vec![], +/// warnings: vec![], +/// }; +/// assert!(result.is_valid); +/// ``` +#[derive(Debug, Clone)] +pub struct ValidationResult { + /// Whether the document is valid according to the schema. + pub is_valid: bool, + /// Validation errors (each one makes the document invalid). + pub errors: Vec<ValidationError>, + /// Validation warnings (informational, do not affect validity). + pub warnings: Vec<ValidationError>, +} + +/// A validation error or warning with optional source location. +/// +/// Carries a human-readable message and optional line/column information +/// for pinpointing the issue in the source document. +#[derive(Debug, Clone)] +pub struct ValidationError { + /// Human-readable description of the validation issue. + pub message: String, + /// The 1-based line number where the issue was detected, if known. + pub line: Option<usize>, + /// The 1-based column number where the issue was detected, if known. + pub column: Option<usize>, +} + +impl fmt::Display for ValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match (self.line, self.column) { + (Some(line), Some(col)) => write!(f, "{}:{}: {}", line, col, self.message), + (Some(line), None) => write!(f, "line {}: {}", line, self.message), + _ => write!(f, "{}", self.message), + } + } +} + +impl fmt::Display for ValidationResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.is_valid { + write!(f, "valid")?; + } else { + write!(f, "invalid ({} error(s))", self.errors.len())?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validation_error_display_with_location() { + let err = ValidationError { + message: "missing required attribute".to_string(), + line: Some(5), + column: Some(10), + }; + assert_eq!(err.to_string(), "5:10: missing required attribute"); + } + + #[test] + fn test_validation_error_display_line_only() { + let err = ValidationError { + message: "unexpected element".to_string(), + line: Some(3), + column: None, + }; + assert_eq!(err.to_string(), "line 3: unexpected element"); + } + + #[test] + fn test_validation_error_display_no_location() { + let err = ValidationError { + message: "duplicate ID".to_string(), + line: None, + column: None, + }; + assert_eq!(err.to_string(), "duplicate ID"); + } + + #[test] + fn test_validation_result_display() { + let valid = ValidationResult { + is_valid: true, + errors: vec![], + warnings: vec![], + }; + assert_eq!(valid.to_string(), "valid"); + + let invalid = ValidationResult { + is_valid: false, + errors: vec![ + ValidationError { + message: "error 1".to_string(), + line: None, + column: None, + }, + ValidationError { + message: "error 2".to_string(), + line: None, + column: None, + }, + ], + warnings: vec![], + }; + assert_eq!(invalid.to_string(), "invalid (2 error(s))"); + } +} diff --git a/browser/vendor/xmloxide/src/validation/relaxng.rs b/browser/vendor/xmloxide/src/validation/relaxng.rs new file mode 100644 index 000000000..5e210e8d1 --- /dev/null +++ b/browser/vendor/xmloxide/src/validation/relaxng.rs @@ -0,0 +1,2479 @@ +//! `RelaxNG` schema validation for XML documents. +//! +//! This module implements the `RelaxNG` specification +//! (<https://relaxng.org/spec-20011203.html>) for validating XML documents +//! against `RelaxNG` schemas. `RelaxNG` schemas are themselves XML documents +//! that describe the structure and content of valid XML. +//! +//! # Architecture +//! +//! The implementation is split into three layers: +//! +//! 1. **Data model** ([`Pattern`], [`NameClass`], [`RelaxNgSchema`]) — an +//! algebraic representation of the schema grammar. +//! 2. **Schema parser** ([`parse_relaxng`]) — reads a `RelaxNG` XML schema +//! document and produces a `RelaxNgSchema`. +//! 3. **Validator** ([`validate`]) — checks an XML document tree against a +//! compiled schema using a recursive pattern-matching approach. +//! +//! # Examples +//! +//! ``` +//! use xmloxide::Document; +//! use xmloxide::validation::relaxng::{parse_relaxng, validate}; +//! +//! let schema_xml = r#" +//! <element name="greeting" xmlns="http://relaxng.org/ns/structure/1.0"> +//! <text/> +//! </element> +//! "#; +//! +//! let schema = parse_relaxng(schema_xml).unwrap(); +//! let doc = Document::parse_str("<greeting>Hello!</greeting>").unwrap(); +//! let result = validate(&doc, &schema); +//! assert!(result.is_valid); +//! ``` + +use std::collections::HashMap; +use std::fmt; + +use crate::tree::{Document, NodeId, NodeKind}; +use crate::validation::{ValidationError, ValidationResult}; + +// --------------------------------------------------------------------------- +// Data model +// --------------------------------------------------------------------------- + +/// A `RelaxNG` pattern — the core building block of a schema grammar. +/// +/// Patterns form a tree that describes the allowed structure and content +/// of XML documents. The variants correspond to the grammar constructs +/// defined in the `RelaxNG` specification. +/// +/// See <https://relaxng.org/spec-20011203.html#section:patterns>. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Pattern { + /// Matches empty content (no elements, no text). + Empty, + /// Matches nothing — always fails. Used as an identity element for choice. + NotAllowed, + /// Matches arbitrary text content. + Text, + /// Matches an element whose name satisfies the [`NameClass`] and whose + /// content matches the inner pattern. + Element { + /// Name constraint for the element. + name: NameClass, + /// Pattern that the element's content must match. + pattern: Box<Pattern>, + }, + /// Matches an attribute whose name satisfies the [`NameClass`] and whose + /// value matches the inner pattern. + Attribute { + /// Name constraint for the attribute. + name: NameClass, + /// Pattern that the attribute value must match. + pattern: Box<Pattern>, + }, + /// Sequential composition — first pattern then second pattern. + Group(Box<Pattern>, Box<Pattern>), + /// Interleave — both patterns must match but in any order. + Interleave(Box<Pattern>, Box<Pattern>), + /// Choice — one of the two patterns must match. + Choice(Box<Pattern>, Box<Pattern>), + /// Optional — zero or one occurrence. + Optional(Box<Pattern>), + /// Zero or more occurrences. + ZeroOrMore(Box<Pattern>), + /// One or more occurrences. + OneOrMore(Box<Pattern>), + /// A named reference to a `<define>` block in the grammar. + Ref(String), + /// Matches a whitespace-separated list of tokens against the inner pattern. + List(Box<Pattern>), + /// Matches an exact string value. + Value(String), + /// Matches a value against a named datatype from a datatype library. + Data { + /// The datatype name (e.g., `"integer"`, `"string"`). + datatype: String, + /// The datatype library URI (e.g., the XML Schema datatypes namespace). + library: String, + }, + /// Matches a mixed content model (interleave of text and a pattern). + Mixed(Box<Pattern>), +} + +impl fmt::Display for Pattern { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "empty"), + Self::NotAllowed => write!(f, "notAllowed"), + Self::Text => write!(f, "text"), + Self::Element { name, .. } => write!(f, "element {name}"), + Self::Attribute { name, .. } => write!(f, "attribute {name}"), + Self::Group(a, b) => write!(f, "group({a}, {b})"), + Self::Interleave(a, b) => write!(f, "interleave({a}, {b})"), + Self::Choice(a, b) => write!(f, "choice({a}, {b})"), + Self::Optional(p) => write!(f, "optional({p})"), + Self::ZeroOrMore(p) => write!(f, "zeroOrMore({p})"), + Self::OneOrMore(p) => write!(f, "oneOrMore({p})"), + Self::Ref(name) => write!(f, "ref({name})"), + Self::List(p) => write!(f, "list({p})"), + Self::Value(v) => write!(f, "value(\"{v}\")"), + Self::Data { datatype, .. } => write!(f, "data({datatype})"), + Self::Mixed(p) => write!(f, "mixed({p})"), + } + } +} + +/// A name class — constrains which element or attribute names are allowed. +/// +/// Name classes can match specific names, any name in a namespace, +/// any name at all, or combinations via choice and exclusion. +/// +/// See <https://relaxng.org/spec-20011203.html#section:name-classes>. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NameClass { + /// Matches a specific name (namespace URI + local name). + Name { + /// The namespace URI (empty string for no namespace). + ns: String, + /// The local name of the element or attribute. + local: String, + }, + /// Matches any name regardless of namespace. + AnyName, + /// Matches any name except those matching the excluded name class. + AnyNameExcept(Box<NameClass>), + /// Matches any name in the given namespace. + NsName { + /// The namespace URI to match. + ns: String, + }, + /// Matches any name in the given namespace except those matching the + /// excluded name class. + NsNameExcept { + /// The namespace URI to match. + ns: String, + /// Names to exclude. + except: Box<NameClass>, + }, + /// Choice of two name classes — matches if either matches. + Choice(Box<NameClass>, Box<NameClass>), +} + +impl NameClass { + /// Tests whether this name class matches the given namespace and local name. + #[must_use] + pub fn matches(&self, ns: &str, local: &str) -> bool { + match self { + Self::Name { + ns: expected_ns, + local: expected_local, + } => expected_ns == ns && expected_local == local, + Self::AnyName => true, + Self::AnyNameExcept(except) => !except.matches(ns, local), + Self::NsName { ns: expected_ns } => expected_ns == ns, + Self::NsNameExcept { + ns: expected_ns, + except, + } => expected_ns == ns && !except.matches(ns, local), + Self::Choice(a, b) => a.matches(ns, local) || b.matches(ns, local), + } + } +} + +impl fmt::Display for NameClass { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Name { ns, local } => { + if ns.is_empty() { + write!(f, "{local}") + } else { + write!(f, "{{{ns}}}{local}") + } + } + Self::AnyName => write!(f, "*"), + Self::AnyNameExcept(except) => write!(f, "* - {except}"), + Self::NsName { ns } => write!(f, "{{{ns}}}*"), + Self::NsNameExcept { ns, except } => write!(f, "{{{ns}}}* - {except}"), + Self::Choice(a, b) => write!(f, "{a} | {b}"), + } + } +} + +/// A compiled `RelaxNG` schema ready for validation. +/// +/// Contains the start pattern (the entry point for validation) and a map +/// of named definitions that can be referenced via `Ref` patterns. +#[derive(Debug, Clone)] +pub struct RelaxNgSchema { + /// The start pattern — the root document must match this. + pub start: Pattern, + /// Named definitions (`<define name="...">` blocks). + pub defines: HashMap<String, Pattern>, +} + +// --------------------------------------------------------------------------- +// Schema parsing errors +// --------------------------------------------------------------------------- + +/// Error type for schema parsing failures. +#[derive(Debug, Clone)] +pub struct SchemaParseError { + /// Human-readable description of what went wrong. + pub message: String, +} + +impl fmt::Display for SchemaParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "RelaxNG schema error: {}", self.message) + } +} + +impl std::error::Error for SchemaParseError {} + +// --------------------------------------------------------------------------- +// Schema parser +// --------------------------------------------------------------------------- + +/// Parses a `RelaxNG` XML schema string into a [`RelaxNgSchema`]. +/// +/// The input must be a valid XML document conforming to the `RelaxNG` XML +/// syntax (<https://relaxng.org/spec-20011203.html>). Both compact-element +/// form (e.g., `<element name="foo">`) and verbose form with child `<name>` +/// elements are supported. +/// +/// # Errors +/// +/// Returns [`SchemaParseError`] if the input is not well-formed XML or does +/// not conform to the expected `RelaxNG` structure. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::validation::relaxng::parse_relaxng; +/// +/// let schema = parse_relaxng(r#" +/// <element name="root" xmlns="http://relaxng.org/ns/structure/1.0"> +/// <empty/> +/// </element> +/// "#).unwrap(); +/// ``` +pub fn parse_relaxng(schema_xml: &str) -> Result<RelaxNgSchema, SchemaParseError> { + let doc = Document::parse_str(schema_xml).map_err(|e| SchemaParseError { + message: format!("failed to parse schema XML: {e}"), + })?; + + let root_el = doc.root_element().ok_or_else(|| SchemaParseError { + message: "schema has no root element".to_string(), + })?; + + let root_name = doc.node_name(root_el).unwrap_or(""); + + // Determine default namespace for elements from the `ns` attribute + // on the root schema element. + let default_ns = element_ns_attr(&doc, root_el); + + if strip_rng_prefix(root_name) == "grammar" { + parse_grammar(&doc, root_el, &default_ns) + } else if strip_rng_prefix(root_name) == "element" { + // Top-level element pattern (short form, no grammar wrapper). + let pattern = parse_element_pattern(&doc, root_el, &default_ns)?; + Ok(RelaxNgSchema { + start: pattern, + defines: HashMap::new(), + }) + } else { + Err(SchemaParseError { + message: format!("expected <grammar> or <element> as root, found <{root_name}>"), + }) + } +} + +/// Strips a `rng:` prefix from a name, if present, returning the local part. +fn strip_rng_prefix(name: &str) -> &str { + name.strip_prefix("rng:").unwrap_or(name) +} + +/// Returns the value of the `ns` attribute on an element, defaulting to +/// the empty string. +fn element_ns_attr(doc: &Document, node: NodeId) -> String { + doc.attribute(node, "ns").unwrap_or("").to_string() +} + +/// Parses a `<grammar>` element containing `<start>` and `<define>` children. +fn parse_grammar( + doc: &Document, + grammar_el: NodeId, + parent_ns: &str, +) -> Result<RelaxNgSchema, SchemaParseError> { + let mut start: Option<Pattern> = None; + let mut defines: HashMap<String, Pattern> = HashMap::new(); + + let ns = resolve_ns(doc, grammar_el, parent_ns); + + for child in doc.children(grammar_el) { + if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { + continue; + } + let child_name = doc.node_name(child).unwrap_or(""); + let local = strip_rng_prefix(child_name); + + match local { + "start" => { + let inner = parse_pattern_children(doc, child, &ns)?; + start = Some(inner); + } + "define" => { + let name = doc + .attribute(child, "name") + .ok_or_else(|| SchemaParseError { + message: "<define> missing 'name' attribute".to_string(), + })? + .to_string(); + let inner = parse_pattern_children(doc, child, &ns)?; + defines.insert(name, inner); + } + _ => { + // Ignore unknown elements (includes, divs, etc. — not yet + // implemented). + } + } + } + + let start = start.ok_or_else(|| SchemaParseError { + message: "<grammar> has no <start> element".to_string(), + })?; + + Ok(RelaxNgSchema { start, defines }) +} + +/// Resolves the effective namespace for pattern children. Uses the `ns` +/// attribute on the current element if present, otherwise inherits. +fn resolve_ns(doc: &Document, el: NodeId, parent_ns: &str) -> String { + doc.attribute(el, "ns") + .map_or_else(|| parent_ns.to_string(), String::from) +} + +/// Parses the child patterns of a container element (e.g., `<start>`, +/// `<group>`, `<choice>`). If there are multiple children, they are +/// implicitly grouped. +fn parse_pattern_children( + doc: &Document, + container: NodeId, + ns: &str, +) -> Result<Pattern, SchemaParseError> { + let patterns = collect_child_patterns(doc, container, ns)?; + combine_patterns(patterns) +} + +/// Collects all child pattern elements from a container node. +fn collect_child_patterns( + doc: &Document, + container: NodeId, + ns: &str, +) -> Result<Vec<Pattern>, SchemaParseError> { + let mut patterns = Vec::new(); + for child in doc.children(container) { + if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { + continue; + } + let p = parse_pattern(doc, child, ns)?; + patterns.push(p); + } + Ok(patterns) +} + +/// Combines a list of patterns using implicit `Group` (sequential composition). +fn combine_patterns(patterns: Vec<Pattern>) -> Result<Pattern, SchemaParseError> { + if patterns.is_empty() { + return Ok(Pattern::Empty); + } + + let mut iter = patterns.into_iter(); + let first = iter.next().ok_or_else(|| SchemaParseError { + message: "internal error: empty pattern list".to_string(), + })?; + + Ok(iter.fold(first, |acc, p| Pattern::Group(Box::new(acc), Box::new(p)))) +} + +/// Parses a single pattern element. +fn parse_pattern(doc: &Document, el: NodeId, parent_ns: &str) -> Result<Pattern, SchemaParseError> { + let name = doc.node_name(el).unwrap_or(""); + let local = strip_rng_prefix(name); + let ns = resolve_ns(doc, el, parent_ns); + + match local { + "element" => parse_element_pattern(doc, el, &ns), + "attribute" => parse_attribute_pattern(doc, el, &ns), + "group" => { + let children = collect_child_patterns(doc, el, &ns)?; + combine_patterns(children) + } + "interleave" => { + let children = collect_child_patterns(doc, el, &ns)?; + combine_interleave(children) + } + "choice" => { + let children = collect_child_patterns(doc, el, &ns)?; + combine_choice(children) + } + "optional" => { + let inner = parse_pattern_children(doc, el, &ns)?; + Ok(Pattern::Optional(Box::new(inner))) + } + "zeroOrMore" => { + let inner = parse_pattern_children(doc, el, &ns)?; + Ok(Pattern::ZeroOrMore(Box::new(inner))) + } + "oneOrMore" => { + let inner = parse_pattern_children(doc, el, &ns)?; + Ok(Pattern::OneOrMore(Box::new(inner))) + } + "mixed" => { + let inner = parse_pattern_children(doc, el, &ns)?; + Ok(Pattern::Mixed(Box::new(inner))) + } + "ref" => { + let ref_name = doc + .attribute(el, "name") + .ok_or_else(|| SchemaParseError { + message: "<ref> missing 'name' attribute".to_string(), + })? + .to_string(); + Ok(Pattern::Ref(ref_name)) + } + "text" => Ok(Pattern::Text), + "empty" => Ok(Pattern::Empty), + "notAllowed" => Ok(Pattern::NotAllowed), + "value" => { + let text = doc.text_content(el); + Ok(Pattern::Value(text)) + } + "data" => { + let datatype = doc.attribute(el, "type").unwrap_or("string").to_string(); + let library = doc + .attribute(el, "datatypeLibrary") + .unwrap_or("") + .to_string(); + Ok(Pattern::Data { datatype, library }) + } + "list" => { + let inner = parse_pattern_children(doc, el, &ns)?; + Ok(Pattern::List(Box::new(inner))) + } + other => Err(SchemaParseError { + message: format!("unknown pattern element <{other}>"), + }), + } +} + +/// Parses an `<element>` pattern, extracting the name class and content pattern. +fn parse_element_pattern( + doc: &Document, + el: NodeId, + ns: &str, +) -> Result<Pattern, SchemaParseError> { + let name_class = parse_name_class_from_element(doc, el, ns)?; + let el_ns = resolve_ns(doc, el, ns); + + // Collect content patterns (everything except <name>, <anyName>, <nsName>, + // <choice> when used as name class). + let mut content_patterns = Vec::new(); + for child in doc.children(el) { + if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { + continue; + } + let child_name = doc.node_name(child).unwrap_or(""); + let child_local = strip_rng_prefix(child_name); + // Skip name-class children — they're handled by parse_name_class. + if is_name_class_element(child_local) && doc.attribute(el, "name").is_none() { + continue; + } + let p = parse_pattern(doc, child, &el_ns)?; + content_patterns.push(p); + } + + let content = combine_patterns(content_patterns)?; + + Ok(Pattern::Element { + name: name_class, + pattern: Box::new(content), + }) +} + +/// Parses an `<attribute>` pattern, extracting the name class and value pattern. +fn parse_attribute_pattern( + doc: &Document, + el: NodeId, + ns: &str, +) -> Result<Pattern, SchemaParseError> { + let name_class = parse_name_class_from_element(doc, el, ns)?; + let attr_ns = resolve_ns(doc, el, ns); + + let mut content_patterns = Vec::new(); + for child in doc.children(el) { + if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { + continue; + } + let child_name = doc.node_name(child).unwrap_or(""); + let child_local = strip_rng_prefix(child_name); + if is_name_class_element(child_local) && doc.attribute(el, "name").is_none() { + continue; + } + let p = parse_pattern(doc, child, &attr_ns)?; + content_patterns.push(p); + } + + let content = if content_patterns.is_empty() { + Pattern::Text // default: attribute value is text + } else { + combine_patterns(content_patterns)? + }; + + Ok(Pattern::Attribute { + name: name_class, + pattern: Box::new(content), + }) +} + +/// Determines whether an element local name is a name-class element. +fn is_name_class_element(local: &str) -> bool { + matches!(local, "name" | "anyName" | "nsName" | "choice") +} + +/// Extracts a [`NameClass`] from an element or attribute pattern element. +/// +/// If the element has a `name` attribute, uses that directly. Otherwise, +/// looks for a child `<name>`, `<anyName>`, or `<nsName>` element. +fn parse_name_class_from_element( + doc: &Document, + el: NodeId, + ns: &str, +) -> Result<NameClass, SchemaParseError> { + // Check for `name` attribute shorthand. + if let Some(name_attr) = doc.attribute(el, "name") { + let el_ns = resolve_ns(doc, el, ns); + return Ok(NameClass::Name { + ns: el_ns, + local: name_attr.to_string(), + }); + } + + // Look for a name-class child element. + for child in doc.children(el) { + if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { + continue; + } + let child_name = doc.node_name(child).unwrap_or(""); + let child_local = strip_rng_prefix(child_name); + match child_local { + "name" => { + let local_name = doc.text_content(child); + let child_ns = resolve_ns(doc, child, ns); + return Ok(NameClass::Name { + ns: child_ns, + local: local_name.trim().to_string(), + }); + } + "anyName" => { + return parse_any_name_class(doc, child, ns); + } + "nsName" => { + return parse_ns_name_class(doc, child, ns); + } + "choice" => { + return parse_name_class_choice(doc, child, ns); + } + _ => {} + } + } + + Err(SchemaParseError { + message: "element/attribute pattern has no name or name class".to_string(), + }) +} + +/// Parses an `<anyName>` name class, possibly with `<except>`. +fn parse_any_name_class( + doc: &Document, + el: NodeId, + ns: &str, +) -> Result<NameClass, SchemaParseError> { + for child in doc.children(el) { + if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { + continue; + } + let child_name = doc.node_name(child).unwrap_or(""); + if strip_rng_prefix(child_name) == "except" { + let except = parse_name_class_children(doc, child, ns)?; + return Ok(NameClass::AnyNameExcept(Box::new(except))); + } + } + Ok(NameClass::AnyName) +} + +/// Parses an `<nsName>` name class, possibly with `<except>`. +fn parse_ns_name_class( + doc: &Document, + el: NodeId, + ns: &str, +) -> Result<NameClass, SchemaParseError> { + let target_ns = resolve_ns(doc, el, ns); + for child in doc.children(el) { + if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { + continue; + } + let child_name = doc.node_name(child).unwrap_or(""); + if strip_rng_prefix(child_name) == "except" { + let except = parse_name_class_children(doc, child, ns)?; + return Ok(NameClass::NsNameExcept { + ns: target_ns, + except: Box::new(except), + }); + } + } + Ok(NameClass::NsName { ns: target_ns }) +} + +/// Parses a `<choice>` element used as a name class. +fn parse_name_class_choice( + doc: &Document, + el: NodeId, + ns: &str, +) -> Result<NameClass, SchemaParseError> { + let mut classes = Vec::new(); + for child in doc.children(el) { + if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { + continue; + } + let child_name = doc.node_name(child).unwrap_or(""); + let child_local = strip_rng_prefix(child_name); + let nc = match child_local { + "name" => { + let local_name = doc.text_content(child).trim().to_string(); + let child_ns = resolve_ns(doc, child, ns); + NameClass::Name { + ns: child_ns, + local: local_name, + } + } + "anyName" => parse_any_name_class(doc, child, ns)?, + "nsName" => parse_ns_name_class(doc, child, ns)?, + "choice" => parse_name_class_choice(doc, child, ns)?, + _ => { + continue; + } + }; + classes.push(nc); + } + combine_name_classes(classes) +} + +/// Parses name class children inside an `<except>` or similar container. +fn parse_name_class_children( + doc: &Document, + container: NodeId, + ns: &str, +) -> Result<NameClass, SchemaParseError> { + let mut classes = Vec::new(); + for child in doc.children(container) { + if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { + continue; + } + let child_name = doc.node_name(child).unwrap_or(""); + let child_local = strip_rng_prefix(child_name); + let nc = match child_local { + "name" => { + let local_name = doc.text_content(child).trim().to_string(); + let child_ns = resolve_ns(doc, child, ns); + NameClass::Name { + ns: child_ns, + local: local_name, + } + } + "anyName" => parse_any_name_class(doc, child, ns)?, + "nsName" => parse_ns_name_class(doc, child, ns)?, + "choice" => parse_name_class_choice(doc, child, ns)?, + _ => { + continue; + } + }; + classes.push(nc); + } + combine_name_classes(classes) +} + +/// Combines multiple name classes using `Choice`. +fn combine_name_classes(classes: Vec<NameClass>) -> Result<NameClass, SchemaParseError> { + if classes.is_empty() { + return Err(SchemaParseError { + message: "empty name class".to_string(), + }); + } + let mut iter = classes.into_iter(); + let first = iter.next().ok_or_else(|| SchemaParseError { + message: "internal error: empty name class list".to_string(), + })?; + Ok(iter.fold(first, |acc, nc| { + NameClass::Choice(Box::new(acc), Box::new(nc)) + })) +} + +/// Combines patterns using `Interleave`. +fn combine_interleave(patterns: Vec<Pattern>) -> Result<Pattern, SchemaParseError> { + if patterns.is_empty() { + return Ok(Pattern::Empty); + } + let mut iter = patterns.into_iter(); + let first = iter.next().ok_or_else(|| SchemaParseError { + message: "internal error: empty interleave list".to_string(), + })?; + Ok(iter.fold(first, |acc, p| { + Pattern::Interleave(Box::new(acc), Box::new(p)) + })) +} + +/// Combines patterns using `Choice`. +fn combine_choice(patterns: Vec<Pattern>) -> Result<Pattern, SchemaParseError> { + if patterns.is_empty() { + return Ok(Pattern::NotAllowed); + } + let mut iter = patterns.into_iter(); + let first = iter.next().ok_or_else(|| SchemaParseError { + message: "internal error: empty choice list".to_string(), + })?; + Ok(iter.fold(first, |acc, p| Pattern::Choice(Box::new(acc), Box::new(p)))) +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/// Validates an XML document against a compiled [`RelaxNgSchema`]. +/// +/// The validator checks that the document's root element matches the +/// schema's start pattern, recursively verifying element names, attributes, +/// text content, and structural constraints. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::Document; +/// use xmloxide::validation::relaxng::{parse_relaxng, validate}; +/// +/// let schema = parse_relaxng(r#" +/// <element name="root" xmlns="http://relaxng.org/ns/structure/1.0"> +/// <empty/> +/// </element> +/// "#).unwrap(); +/// +/// let doc = Document::parse_str("<root/>").unwrap(); +/// let result = validate(&doc, &schema); +/// assert!(result.is_valid); +/// ``` +#[must_use] +pub fn validate(doc: &Document, schema: &RelaxNgSchema) -> ValidationResult { + let mut errors = Vec::new(); + + let Some(root_el) = doc.root_element() else { + return ValidationResult { + is_valid: false, + errors: vec![ValidationError { + message: "document has no root element".to_string(), + line: None, + column: None, + }], + warnings: Vec::new(), + }; + }; + + let ctx = ValidationContext { + doc, + defines: &schema.defines, + }; + + let ok = ctx.validate_node(root_el, &schema.start, &mut errors); + + ValidationResult { + is_valid: ok && errors.is_empty(), + errors, + warnings: Vec::new(), + } +} + +/// Internal validation context — carries references to the document and schema. +struct ValidationContext<'a> { + doc: &'a Document, + defines: &'a HashMap<String, Pattern>, +} + +impl ValidationContext<'_> { + /// Validates a node against a pattern. Returns `true` if the node matches. + fn validate_node( + &self, + node: NodeId, + pattern: &Pattern, + errors: &mut Vec<ValidationError>, + ) -> bool { + match pattern { + Pattern::Element { + name, + pattern: inner, + } => self.validate_element(node, name, inner, errors), + Pattern::Choice(a, b) => { + // Try the first alternative silently; if it fails, try the second. + let mut a_errors = Vec::new(); + if self.validate_node(node, a, &mut a_errors) { + return true; + } + let mut b_errors = Vec::new(); + if self.validate_node(node, b, &mut b_errors) { + return true; + } + // Both failed — report the second branch errors (usually more + // informative for the "expected" case). + errors.extend(b_errors); + false + } + Pattern::Ref(ref_name) => { + if let Some(def) = self.defines.get(ref_name) { + self.validate_node(node, def, errors) + } else { + errors.push(ValidationError { + message: format!("undefined reference: {ref_name}"), + line: None, + column: None, + }); + false + } + } + _ => { + // For top-level validation, only Element patterns make sense + // as the start. If a non-element pattern appears at the root, + // it means the schema is unusual. + errors.push(ValidationError { + message: format!( + "expected document root to match {pattern}, \ + but root validation requires an element pattern" + ), + line: None, + column: None, + }); + false + } + } + } + + /// Validates an element node against an element pattern. + fn validate_element( + &self, + node: NodeId, + name_class: &NameClass, + content_pattern: &Pattern, + errors: &mut Vec<ValidationError>, + ) -> bool { + let node_kind = &self.doc.node(node).kind; + + // Ensure the node is actually an element. + let (el_name, el_ns) = if let NodeKind::Element { + name, namespace, .. + } = node_kind + { + (name.as_str(), namespace.as_deref().unwrap_or("")) + } else { + errors.push(ValidationError { + message: "expected element node".to_string(), + line: None, + column: None, + }); + return false; + }; + + // Check name. + if !name_class.matches(el_ns, el_name) { + errors.push(ValidationError { + message: format!( + "element name mismatch: found <{el_name}>, \ + expected {name_class}" + ), + line: None, + column: None, + }); + return false; + } + + // Validate content (attributes + children). + self.validate_content(node, content_pattern, errors) + } + + /// Validates the content of an element (attributes and child nodes) + /// against a content pattern. + fn validate_content( + &self, + element: NodeId, + pattern: &Pattern, + errors: &mut Vec<ValidationError>, + ) -> bool { + let attrs = self.doc.attributes(element); + let children: Vec<NodeId> = self.doc.children(element).collect(); + + // Separate attribute patterns from child-content patterns. + let (attr_patterns, content_pattern) = split_attributes(pattern); + + // Validate attributes. + let mut attr_ok = true; + let mut matched_attrs: Vec<bool> = vec![false; attrs.len()]; + + for ap in &attr_patterns { + if let Pattern::Attribute { + name: name_class, + pattern: value_pattern, + } = ap + { + let found = self.validate_attribute( + attrs, + &mut matched_attrs, + name_class, + value_pattern, + errors, + ); + if !found { + attr_ok = false; + } + } else if let Pattern::Optional(inner) = ap { + if let Pattern::Attribute { + name: name_class, + pattern: value_pattern, + } = inner.as_ref() + { + // Optional attribute: try to match but don't report error + // if missing. + let mut tmp_errors = Vec::new(); + let _ = self.validate_attribute( + attrs, + &mut matched_attrs, + name_class, + value_pattern, + &mut tmp_errors, + ); + // Ignore "missing" errors for optional attributes but + // keep value-mismatch errors. + for err in tmp_errors { + if !err.message.contains("missing required") { + errors.push(err); + attr_ok = false; + } + } + } + } + } + + // Check for unmatched (unexpected) attributes — but only if we had + // attribute patterns. If the content pattern is AnyName-style, we + // skip this check. + if !attr_patterns.is_empty() || !has_wildcard_attribute(pattern) { + for (i, attr) in attrs.iter().enumerate() { + if !matched_attrs[i] && !is_xmlns_attribute(attr) { + errors.push(ValidationError { + message: format!( + "unexpected attribute '{}' on element '{}'", + attr.name, + self.doc.node_name(element).unwrap_or("<unknown>") + ), + line: None, + column: None, + }); + attr_ok = false; + } + } + } + + // Validate child content. + let content_ok = self.validate_children(&children, &content_pattern, 0, errors); + + attr_ok && content_ok + } + + /// Checks if a specific attribute is present and its value matches. + fn validate_attribute( + &self, + attrs: &[crate::tree::Attribute], + matched: &mut [bool], + name_class: &NameClass, + value_pattern: &Pattern, + errors: &mut Vec<ValidationError>, + ) -> bool { + for (i, attr) in attrs.iter().enumerate() { + if matched[i] { + continue; + } + let attr_ns = attr.namespace.as_deref().unwrap_or(""); + if name_class.matches(attr_ns, &attr.name) { + matched[i] = true; + return self.validate_attribute_value( + &attr.value, + &attr.name, + value_pattern, + errors, + ); + } + } + + // Attribute not found. + errors.push(ValidationError { + message: format!("missing required attribute {name_class}"), + line: None, + column: None, + }); + false + } + + /// Validates an attribute value against a pattern. + fn validate_attribute_value( + &self, + value: &str, + attr_name: &str, + pattern: &Pattern, + errors: &mut Vec<ValidationError>, + ) -> bool { + match pattern { + Pattern::Value(expected) => { + if value == expected { + true + } else { + errors.push(ValidationError { + message: format!( + "attribute '{attr_name}' has value \"{value}\", \ + expected \"{expected}\"" + ), + line: None, + column: None, + }); + false + } + } + Pattern::Choice(a, b) => { + let mut tmp = Vec::new(); + if self.validate_attribute_value(value, attr_name, a, &mut tmp) { + return true; + } + self.validate_attribute_value(value, attr_name, b, errors) + } + Pattern::Data { datatype, library } => { + validate_datatype(value, attr_name, datatype, library, errors) + } + Pattern::List(inner) => { + let tokens: Vec<&str> = value.split_whitespace().collect(); + self.validate_list_tokens(&tokens, attr_name, inner, errors) + } + Pattern::Ref(ref_name) => { + if let Some(def) = self.defines.get(ref_name) { + self.validate_attribute_value(value, attr_name, def, errors) + } else { + errors.push(ValidationError { + message: format!("undefined reference: {ref_name}"), + line: None, + column: None, + }); + false + } + } + _ => true, // Be permissive for patterns we don't specifically handle. + } + } + + /// Validates a list of whitespace-separated tokens against a pattern. + fn validate_list_tokens( + &self, + tokens: &[&str], + attr_name: &str, + pattern: &Pattern, + errors: &mut Vec<ValidationError>, + ) -> bool { + match pattern { + Pattern::OneOrMore(inner) => { + if tokens.is_empty() { + errors.push(ValidationError { + message: format!( + "attribute '{attr_name}' list must have \ + at least one token" + ), + line: None, + column: None, + }); + return false; + } + tokens + .iter() + .all(|t| self.validate_attribute_value(t, attr_name, inner, errors)) + } + Pattern::ZeroOrMore(inner) => tokens + .iter() + .all(|t| self.validate_attribute_value(t, attr_name, inner, errors)), + _ => { + // Single-token list: validate the first token. + if let Some(t) = tokens.first() { + self.validate_attribute_value(t, attr_name, pattern, errors) + } else { + true + } + } + } + } + + /// Validates child nodes against a content pattern. + /// + /// Returns `true` if the children from `start` onwards match the pattern. + fn validate_children( + &self, + children: &[NodeId], + pattern: &Pattern, + start: usize, + errors: &mut Vec<ValidationError>, + ) -> bool { + // Filter to significant children (elements and non-whitespace text). + let significant: Vec<(usize, NodeId)> = children[start..] + .iter() + .enumerate() + .filter(|(_, &id)| match &self.doc.node(id).kind { + NodeKind::Text { content } => !content.trim().is_empty(), + NodeKind::Element { .. } | NodeKind::CData { .. } => true, + _ => false, // Skip comments, PIs + }) + .map(|(i, &id)| (start + i, id)) + .collect(); + + self.match_children(&significant, 0, pattern, errors) + } + + /// Recursive child-pattern matcher. Returns `true` if the significant + /// children from `pos` onwards match the given pattern. + #[allow(clippy::too_many_lines)] + fn match_children( + &self, + children: &[(usize, NodeId)], + pos: usize, + pattern: &Pattern, + errors: &mut Vec<ValidationError>, + ) -> bool { + match pattern { + Pattern::Empty => self.match_empty(children, pos, errors), + Pattern::NotAllowed => { + errors.push(ValidationError { + message: "content is not allowed here".to_string(), + line: None, + column: None, + }); + false + } + Pattern::Text => self.match_text(children, pos, errors), + Pattern::Element { + name, + pattern: inner, + } => self.match_element_child(children, pos, name, inner, errors), + Pattern::Group(a, b) => self.match_group(children, pos, a, b, errors), + Pattern::Choice(a, b) => { + let mut a_errors = Vec::new(); + if self.match_children(children, pos, a, &mut a_errors) { + return true; + } + let mut b_errors = Vec::new(); + if self.match_children(children, pos, b, &mut b_errors) { + return true; + } + errors.extend(b_errors); + false + } + Pattern::Optional(inner) => { + let mut tmp = Vec::new(); + if self.match_children(children, pos, inner, &mut tmp) { + return true; + } + // Optional: also allow zero matches. + self.match_children(children, pos, &Pattern::Empty, errors) + } + Pattern::ZeroOrMore(inner) => self.match_zero_or_more(children, pos, inner, errors), + Pattern::OneOrMore(inner) => self.match_one_or_more(children, pos, inner, errors), + Pattern::Interleave(a, b) => self.match_interleave(children, pos, a, b, errors), + Pattern::Mixed(inner) => { + let elements: Vec<(usize, NodeId)> = children[pos..] + .iter() + .filter(|(_, id)| matches!(self.doc.node(*id).kind, NodeKind::Element { .. })) + .copied() + .collect(); + self.match_children(&elements, 0, inner, errors) + } + Pattern::Value(expected) => { + let text = self.collect_children_text(children, pos); + if text.trim() == expected.trim() { + true + } else { + errors.push(ValidationError { + message: format!("expected value \"{expected}\", found \"{text}\""), + line: None, + column: None, + }); + false + } + } + Pattern::Data { datatype, library } => { + let text = self.collect_children_text(children, pos); + validate_datatype(text.trim(), "<text>", datatype, library, errors) + } + Pattern::List(inner) => { + let text = self.collect_children_text(children, pos); + let tokens: Vec<&str> = text.split_whitespace().collect(); + self.validate_list_tokens(&tokens, "<text>", inner, errors) + } + Pattern::Ref(ref_name) => { + if let Some(def) = self.defines.get(ref_name) { + self.match_children(children, pos, def, errors) + } else { + errors.push(ValidationError { + message: format!("undefined reference: {ref_name}"), + line: None, + column: None, + }); + false + } + } + Pattern::Attribute { .. } => { + // Attribute patterns in content position are already handled + // by the attribute validation pass. They match empty content. + pos >= children.len() + } + } + } + + /// Matches an `Empty` pattern against remaining children. + fn match_empty( + &self, + children: &[(usize, NodeId)], + pos: usize, + errors: &mut Vec<ValidationError>, + ) -> bool { + if pos >= children.len() { + return true; + } + for &(_, id) in &children[pos..] { + if let NodeKind::Text { content } = &self.doc.node(id).kind { + if content.trim().is_empty() { + continue; + } + } + let desc = self.node_description(id); + errors.push(ValidationError { + message: format!("unexpected content: {desc} (expected empty)"), + line: None, + column: None, + }); + return false; + } + true + } + + /// Matches a `Text` pattern against remaining children. + fn match_text( + &self, + children: &[(usize, NodeId)], + pos: usize, + errors: &mut Vec<ValidationError>, + ) -> bool { + for &(_, id) in &children[pos..] { + match &self.doc.node(id).kind { + NodeKind::Text { .. } | NodeKind::CData { .. } => {} + _ => { + let desc = self.node_description(id); + errors.push(ValidationError { + message: format!("unexpected {desc} (expected text)"), + line: None, + column: None, + }); + return false; + } + } + } + true + } + + /// Matches an element child pattern at a given position. + fn match_element_child( + &self, + children: &[(usize, NodeId)], + pos: usize, + name: &NameClass, + inner: &Pattern, + errors: &mut Vec<ValidationError>, + ) -> bool { + if pos >= children.len() { + errors.push(ValidationError { + message: format!("missing required element {name}"), + line: None, + column: None, + }); + return false; + } + let (_, child_id) = children[pos]; + if !self.validate_element(child_id, name, inner, errors) { + return false; + } + // Ensure no more children after this element. + if pos + 1 < children.len() { + for &(_, id) in &children[pos + 1..] { + let desc = self.node_description(id); + errors.push(ValidationError { + message: format!("unexpected content after element: {desc}"), + line: None, + column: None, + }); + } + return false; + } + true + } + + /// Matches a group pattern (sequential). Tries to find a split point + /// where the first pattern matches `children[pos..split]` and the second + /// matches `children[split..]`. + fn match_group( + &self, + children: &[(usize, NodeId)], + pos: usize, + a: &Pattern, + b: &Pattern, + errors: &mut Vec<ValidationError>, + ) -> bool { + // Try all possible split points. + for split in pos..=children.len() { + let slice_a = &children[..split]; + let mut a_errors = Vec::new(); + if self.match_children(slice_a, pos, a, &mut a_errors) { + let mut b_errors = Vec::new(); + if self.match_children(children, split, b, &mut b_errors) { + return true; + } + } + } + + // None of the split points worked. Generate an error. + let mut a_errors = Vec::new(); + if self.match_children(children, pos, a, &mut a_errors) { + // `a` matched some prefix but `b` couldn't match the rest. + let mut b_errors = Vec::new(); + let _ = self.match_children(children, children.len(), b, &mut b_errors); + errors.extend(b_errors); + } else { + errors.extend(a_errors); + } + false + } + + /// Matches zero or more occurrences of a pattern. + fn match_zero_or_more( + &self, + children: &[(usize, NodeId)], + pos: usize, + inner: &Pattern, + errors: &mut Vec<ValidationError>, + ) -> bool { + // Base case: all children consumed. + if pos >= children.len() { + return true; + } + + // Try to match one occurrence, then recurse for more. + for split in (pos + 1)..=children.len() { + let slice = &children[..split]; + let mut tmp = Vec::new(); + if self.match_children(slice, pos, inner, &mut tmp) { + let mut rest_errors = Vec::new(); + if self.match_zero_or_more(children, split, inner, &mut rest_errors) { + return true; + } + } + } + + // No match at all — check if remaining is empty (whitespace text). + self.match_children(children, pos, &Pattern::Empty, errors) + } + + /// Matches one or more occurrences of a pattern. + fn match_one_or_more( + &self, + children: &[(usize, NodeId)], + pos: usize, + inner: &Pattern, + errors: &mut Vec<ValidationError>, + ) -> bool { + // Must match at least once. + for split in (pos + 1)..=children.len() { + let slice = &children[..split]; + let mut tmp = Vec::new(); + if self.match_children(slice, pos, inner, &mut tmp) { + let mut rest_errors = Vec::new(); + if self.match_zero_or_more(children, split, inner, &mut rest_errors) { + return true; + } + } + } + + // Failed to match even once. + let _ = self.match_children(children, pos, inner, errors); + false + } + + /// Matches an interleave pattern — both sub-patterns must match but + /// in any order. Uses a subset-matching approach. + fn match_interleave( + &self, + children: &[(usize, NodeId)], + pos: usize, + a: &Pattern, + b: &Pattern, + errors: &mut Vec<ValidationError>, + ) -> bool { + let remaining = &children[pos..]; + if remaining.is_empty() { + // Both patterns must accept empty. + let mut tmp = Vec::new(); + let a_ok = self.match_children(&[], 0, a, &mut tmp); + let b_ok = self.match_children(&[], 0, b, &mut tmp); + if !a_ok || !b_ok { + errors.extend(tmp); + } + return a_ok && b_ok; + } + + // Try each possible partitioning of remaining children into + // two subsequences (maintaining order within each) that match + // a and b respectively. + // + // For small numbers of children, we use a bitmask approach where + // each bit indicates whether the child goes to partition A or B. + let n = remaining.len(); + if n > 20 { + // For very large child lists, fall back to a simpler heuristic: + // try matching a first, giving it greedy first pick, then b on rest. + return self.match_interleave_greedy(remaining, a, b, errors); + } + + let total = 1u32 << n; + for mask in 0..total { + let mut a_children: Vec<(usize, NodeId)> = Vec::new(); + let mut b_children: Vec<(usize, NodeId)> = Vec::new(); + for (i, &child) in remaining.iter().enumerate() { + if mask & (1 << i) != 0 { + a_children.push(child); + } else { + b_children.push(child); + } + } + let mut tmp = Vec::new(); + if self.match_children(&a_children, 0, a, &mut tmp) + && self.match_children(&b_children, 0, b, &mut tmp) + { + return true; + } + } + + errors.push(ValidationError { + message: "content does not match interleave pattern".to_string(), + line: None, + column: None, + }); + false + } + + /// Greedy interleave matching for large child lists. + fn match_interleave_greedy( + &self, + children: &[(usize, NodeId)], + a: &Pattern, + b: &Pattern, + errors: &mut Vec<ValidationError>, + ) -> bool { + let mut a_children: Vec<(usize, NodeId)> = Vec::new(); + let mut b_children: Vec<(usize, NodeId)> = Vec::new(); + + for &child in children { + let single = &[child]; + let mut tmp = Vec::new(); + if self.match_children(single, 0, a, &mut tmp) { + a_children.push(child); + } else { + b_children.push(child); + } + } + + let mut a_err = Vec::new(); + let mut b_err = Vec::new(); + let a_ok = self.match_children(&a_children, 0, a, &mut a_err); + let b_ok = self.match_children(&b_children, 0, b, &mut b_err); + + if !a_ok { + errors.extend(a_err); + } + if !b_ok { + errors.extend(b_err); + } + a_ok && b_ok + } + + /// Collects the text content of remaining children as a single string. + fn collect_children_text(&self, children: &[(usize, NodeId)], pos: usize) -> String { + let mut result = String::new(); + for &(_, id) in &children[pos..] { + match &self.doc.node(id).kind { + NodeKind::Text { content } | NodeKind::CData { content } => { + result.push_str(content); + } + _ => {} + } + } + result + } + + /// Returns a human-readable description of a node (for error messages). + fn node_description(&self, id: NodeId) -> String { + match &self.doc.node(id).kind { + NodeKind::Element { name, .. } => format!("element <{name}>"), + NodeKind::Text { content } => { + let truncated = if content.len() > 30 { + format!("\"{}...\"", &content[..30]) + } else { + format!("\"{content}\"") + }; + format!("text {truncated}") + } + NodeKind::CData { content } => { + let truncated = if content.len() > 30 { + format!("\"{}...\"", &content[..30]) + } else { + format!("\"{content}\"") + }; + format!("CDATA {truncated}") + } + NodeKind::Comment { .. } => "comment".to_string(), + NodeKind::ProcessingInstruction { target, .. } => { + format!("PI <?{target}?>") + } + _ => "node".to_string(), + } + } +} + +/// Very basic datatype validation (token, string, integer). +fn validate_datatype( + value: &str, + attr_name: &str, + datatype: &str, + _library: &str, + errors: &mut Vec<ValidationError>, +) -> bool { + match datatype { + "integer" | "int" | "long" | "short" | "byte" => { + if value.trim().parse::<i64>().is_ok() { + true + } else { + errors.push(ValidationError { + message: format!( + "attribute '{attr_name}' value \"{value}\" \ + is not a valid {datatype}" + ), + line: None, + column: None, + }); + false + } + } + "positiveInteger" | "nonNegativeInteger" => match value.trim().parse::<i64>() { + Ok(n) if n >= 0 => true, + _ => { + errors.push(ValidationError { + message: format!( + "attribute '{attr_name}' value \"{value}\" \ + is not a valid {datatype}" + ), + line: None, + column: None, + }); + false + } + }, + "boolean" => { + let v = value.trim(); + if v == "true" || v == "false" || v == "1" || v == "0" { + true + } else { + errors.push(ValidationError { + message: format!( + "attribute '{attr_name}' value \"{value}\" \ + is not a valid boolean" + ), + line: None, + column: None, + }); + false + } + } + _ => true, // Unknown datatypes are accepted. + } +} + +/// Checks whether an attribute is an `xmlns` declaration (which are not +/// validated by `RelaxNG`). +fn is_xmlns_attribute(attr: &crate::tree::Attribute) -> bool { + attr.name == "xmlns" + || attr.prefix.as_deref() == Some("xmlns") + || attr.namespace.as_deref() == Some("http://www.w3.org/2000/xmlns/") +} + +/// Splits a pattern into attribute patterns and the remaining content pattern. +/// +/// This walks the pattern tree and extracts all `Attribute` patterns, +/// returning them separately from the content pattern (which has the +/// attribute patterns replaced with `Empty`). +fn split_attributes(pattern: &Pattern) -> (Vec<Pattern>, Pattern) { + let mut attrs = Vec::new(); + let content = extract_attrs(pattern, &mut attrs); + (attrs, content) +} + +/// Recursively extracts attribute patterns from a pattern tree. +fn extract_attrs(pattern: &Pattern, attrs: &mut Vec<Pattern>) -> Pattern { + match pattern { + Pattern::Attribute { .. } => { + attrs.push(pattern.clone()); + Pattern::Empty + } + Pattern::Group(a, b) => { + let a2 = extract_attrs(a, attrs); + let b2 = extract_attrs(b, attrs); + match (&a2, &b2) { + (Pattern::Empty, _) => b2, + (_, Pattern::Empty) => a2, + _ => Pattern::Group(Box::new(a2), Box::new(b2)), + } + } + Pattern::Interleave(a, b) => { + let a2 = extract_attrs(a, attrs); + let b2 = extract_attrs(b, attrs); + match (&a2, &b2) { + (Pattern::Empty, _) => b2, + (_, Pattern::Empty) => a2, + _ => Pattern::Interleave(Box::new(a2), Box::new(b2)), + } + } + Pattern::Optional(inner) => { + if matches!(inner.as_ref(), Pattern::Attribute { .. }) { + // Optional attribute: still extract it but mark it optional + // by wrapping in Optional. + attrs.push(Pattern::Optional(inner.clone())); + Pattern::Empty + } else { + let inner2 = extract_attrs(inner, attrs); + Pattern::Optional(Box::new(inner2)) + } + } + _ => pattern.clone(), + } +} + +/// Checks whether a pattern tree contains a wildcard attribute pattern +/// (attribute with `AnyName` name class). +fn has_wildcard_attribute(pattern: &Pattern) -> bool { + match pattern { + Pattern::Attribute { + name: NameClass::AnyName, + .. + } => true, + Pattern::Group(a, b) | Pattern::Interleave(a, b) | Pattern::Choice(a, b) => { + has_wildcard_attribute(a) || has_wildcard_attribute(b) + } + Pattern::Optional(p) + | Pattern::ZeroOrMore(p) + | Pattern::OneOrMore(p) + | Pattern::Mixed(p) => has_wildcard_attribute(p), + _ => false, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + // --- Name class matching tests --- + + #[test] + fn test_name_class_specific_name_matches() { + let nc = NameClass::Name { + ns: String::new(), + local: "foo".to_string(), + }; + assert!(nc.matches("", "foo")); + assert!(!nc.matches("", "bar")); + } + + #[test] + fn test_name_class_specific_name_with_ns() { + let nc = NameClass::Name { + ns: "http://example.com".to_string(), + local: "foo".to_string(), + }; + assert!(nc.matches("http://example.com", "foo")); + assert!(!nc.matches("", "foo")); + assert!(!nc.matches("http://example.com", "bar")); + } + + #[test] + fn test_name_class_any_name() { + let nc = NameClass::AnyName; + assert!(nc.matches("", "anything")); + assert!(nc.matches("http://example.com", "anything")); + } + + #[test] + fn test_name_class_any_name_except() { + let nc = NameClass::AnyNameExcept(Box::new(NameClass::Name { + ns: String::new(), + local: "secret".to_string(), + })); + assert!(nc.matches("", "foo")); + assert!(!nc.matches("", "secret")); + } + + #[test] + fn test_name_class_ns_name() { + let nc = NameClass::NsName { + ns: "http://example.com".to_string(), + }; + assert!(nc.matches("http://example.com", "anything")); + assert!(!nc.matches("http://other.com", "anything")); + } + + #[test] + fn test_name_class_ns_name_except() { + let nc = NameClass::NsNameExcept { + ns: "http://example.com".to_string(), + except: Box::new(NameClass::Name { + ns: "http://example.com".to_string(), + local: "secret".to_string(), + }), + }; + assert!(nc.matches("http://example.com", "foo")); + assert!(!nc.matches("http://example.com", "secret")); + assert!(!nc.matches("http://other.com", "foo")); + } + + #[test] + fn test_name_class_choice() { + let nc = NameClass::Choice( + Box::new(NameClass::Name { + ns: String::new(), + local: "a".to_string(), + }), + Box::new(NameClass::Name { + ns: String::new(), + local: "b".to_string(), + }), + ); + assert!(nc.matches("", "a")); + assert!(nc.matches("", "b")); + assert!(!nc.matches("", "c")); + } + + // --- Schema parsing tests --- + + #[test] + fn test_parse_simple_element_schema() { + let schema_xml = r#" + <element name="greeting" xmlns="http://relaxng.org/ns/structure/1.0"> + <text/> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + assert!(matches!(schema.start, Pattern::Element { .. })); + assert!(schema.defines.is_empty()); + } + + #[test] + fn test_parse_grammar_with_start_and_define() { + let schema_xml = r#" + <grammar xmlns="http://relaxng.org/ns/structure/1.0"> + <start> + <ref name="root"/> + </start> + <define name="root"> + <element name="root"> + <text/> + </element> + </define> + </grammar> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + assert!(matches!(schema.start, Pattern::Ref(ref name) if name == "root")); + assert!(schema.defines.contains_key("root")); + } + + #[test] + fn test_parse_element_with_attributes() { + let schema_xml = r#" + <element name="person" xmlns="http://relaxng.org/ns/structure/1.0"> + <attribute name="id"/> + <text/> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let Pattern::Element { pattern, .. } = &schema.start else { + panic!("expected Element pattern, got {:?}", schema.start); + }; + // Should be Group(Attribute, Text) + assert!(matches!(pattern.as_ref(), Pattern::Group(_, _))); + } + + #[test] + fn test_parse_choice_pattern() { + let schema_xml = r#" + <element name="value" xmlns="http://relaxng.org/ns/structure/1.0"> + <choice> + <element name="a"><text/></element> + <element name="b"><text/></element> + </choice> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let Pattern::Element { pattern, .. } = &schema.start else { + panic!("expected Element pattern, got {:?}", schema.start); + }; + assert!(matches!(pattern.as_ref(), Pattern::Choice(_, _))); + } + + #[test] + fn test_parse_zero_or_more() { + let schema_xml = r#" + <element name="list" xmlns="http://relaxng.org/ns/structure/1.0"> + <zeroOrMore> + <element name="item"><text/></element> + </zeroOrMore> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let Pattern::Element { pattern, .. } = &schema.start else { + panic!("expected Element pattern, got {:?}", schema.start); + }; + assert!(matches!(pattern.as_ref(), Pattern::ZeroOrMore(_))); + } + + #[test] + fn test_parse_one_or_more() { + let schema_xml = r#" + <element name="list" xmlns="http://relaxng.org/ns/structure/1.0"> + <oneOrMore> + <element name="item"><text/></element> + </oneOrMore> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let Pattern::Element { pattern, .. } = &schema.start else { + panic!("expected Element pattern, got {:?}", schema.start); + }; + assert!(matches!(pattern.as_ref(), Pattern::OneOrMore(_))); + } + + #[test] + fn test_parse_optional_pattern() { + let schema_xml = r#" + <element name="doc" xmlns="http://relaxng.org/ns/structure/1.0"> + <optional> + <attribute name="lang"/> + </optional> + <text/> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let Pattern::Element { pattern, .. } = &schema.start else { + panic!("expected Element pattern, got {:?}", schema.start); + }; + assert!(matches!(pattern.as_ref(), Pattern::Group(_, _))); + } + + #[test] + fn test_parse_interleave_pattern() { + let schema_xml = r#" + <element name="doc" xmlns="http://relaxng.org/ns/structure/1.0"> + <interleave> + <element name="a"><text/></element> + <element name="b"><text/></element> + </interleave> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let Pattern::Element { pattern, .. } = &schema.start else { + panic!("expected Element pattern, got {:?}", schema.start); + }; + assert!(matches!(pattern.as_ref(), Pattern::Interleave(_, _))); + } + + #[test] + fn test_parse_value_pattern() { + let schema_xml = r#" + <element name="status" xmlns="http://relaxng.org/ns/structure/1.0"> + <value>active</value> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let Pattern::Element { pattern, .. } = &schema.start else { + panic!("expected Element pattern, got {:?}", schema.start); + }; + assert!(matches!(pattern.as_ref(), Pattern::Value(v) if v == "active")); + } + + #[test] + fn test_parse_data_pattern() { + let schema_xml = r#" + <element name="count" xmlns="http://relaxng.org/ns/structure/1.0"> + <data type="integer"/> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let Pattern::Element { pattern, .. } = &schema.start else { + panic!("expected Element pattern, got {:?}", schema.start); + }; + assert!( + matches!(pattern.as_ref(), Pattern::Data { datatype, .. } if datatype == "integer") + ); + } + + // --- Validation tests --- + + #[test] + fn test_validate_simple_element_with_text() { + let schema_xml = r#" + <element name="greeting" xmlns="http://relaxng.org/ns/structure/1.0"> + <text/> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let doc = Document::parse_str("<greeting>Hello!</greeting>").unwrap(); + let result = validate(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_wrong_root_element() { + let schema_xml = r#" + <element name="greeting" xmlns="http://relaxng.org/ns/structure/1.0"> + <text/> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let doc = Document::parse_str("<salutation>Hi</salutation>").unwrap(); + let result = validate(&doc, &schema); + assert!(!result.is_valid); + assert!(!result.errors.is_empty()); + } + + #[test] + fn test_validate_missing_required_attribute() { + let schema_xml = r#" + <element name="person" xmlns="http://relaxng.org/ns/structure/1.0"> + <attribute name="id"/> + <text/> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let doc = Document::parse_str("<person>John</person>").unwrap(); + let result = validate(&doc, &schema); + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| e.message.contains("attribute")), + "expected attribute error, got: {:?}", + result.errors + ); + } + + #[test] + fn test_validate_element_with_attribute() { + let schema_xml = r#" + <element name="person" xmlns="http://relaxng.org/ns/structure/1.0"> + <attribute name="id"/> + <text/> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let doc = Document::parse_str(r#"<person id="42">John</person>"#).unwrap(); + let result = validate(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_unexpected_attribute() { + let schema_xml = r#" + <element name="item" xmlns="http://relaxng.org/ns/structure/1.0"> + <empty/> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let doc = Document::parse_str(r#"<item extra="oops"/>"#).unwrap(); + let result = validate(&doc, &schema); + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| e.message.contains("unexpected attribute")), + "expected unexpected attribute error, got: {:?}", + result.errors + ); + } + + #[test] + fn test_validate_choice_first_alternative() { + let schema_xml = r#" + <element name="value" xmlns="http://relaxng.org/ns/structure/1.0"> + <choice> + <element name="a"><text/></element> + <element name="b"><text/></element> + </choice> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let doc = Document::parse_str("<value><a>hello</a></value>").unwrap(); + let result = validate(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_choice_second_alternative() { + let schema_xml = r#" + <element name="value" xmlns="http://relaxng.org/ns/structure/1.0"> + <choice> + <element name="a"><text/></element> + <element name="b"><text/></element> + </choice> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let doc = Document::parse_str("<value><b>hello</b></value>").unwrap(); + let result = validate(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_choice_invalid() { + let schema_xml = r#" + <element name="value" xmlns="http://relaxng.org/ns/structure/1.0"> + <choice> + <element name="a"><text/></element> + <element name="b"><text/></element> + </choice> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let doc = Document::parse_str("<value><c>hello</c></value>").unwrap(); + let result = validate(&doc, &schema); + assert!(!result.is_valid); + } + + #[test] + fn test_validate_zero_or_more_empty() { + let schema_xml = r#" + <element name="list" xmlns="http://relaxng.org/ns/structure/1.0"> + <zeroOrMore> + <element name="item"><text/></element> + </zeroOrMore> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let doc = Document::parse_str("<list/>").unwrap(); + let result = validate(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_zero_or_more_multiple() { + let schema_xml = r#" + <element name="list" xmlns="http://relaxng.org/ns/structure/1.0"> + <zeroOrMore> + <element name="item"><text/></element> + </zeroOrMore> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let doc = + Document::parse_str("<list><item>a</item><item>b</item><item>c</item></list>").unwrap(); + let result = validate(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_one_or_more_empty_fails() { + let schema_xml = r#" + <element name="list" xmlns="http://relaxng.org/ns/structure/1.0"> + <oneOrMore> + <element name="item"><text/></element> + </oneOrMore> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let doc = Document::parse_str("<list/>").unwrap(); + let result = validate(&doc, &schema); + assert!(!result.is_valid); + } + + #[test] + fn test_validate_one_or_more_with_items() { + let schema_xml = r#" + <element name="list" xmlns="http://relaxng.org/ns/structure/1.0"> + <oneOrMore> + <element name="item"><text/></element> + </oneOrMore> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let doc = Document::parse_str("<list><item>a</item><item>b</item></list>").unwrap(); + let result = validate(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_optional_present() { + let schema_xml = r#" + <element name="doc" xmlns="http://relaxng.org/ns/structure/1.0"> + <optional> + <attribute name="lang"/> + </optional> + <text/> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let doc = Document::parse_str(r#"<doc lang="en">Hello</doc>"#).unwrap(); + let result = validate(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_optional_absent() { + let schema_xml = r#" + <element name="doc" xmlns="http://relaxng.org/ns/structure/1.0"> + <optional> + <attribute name="lang"/> + </optional> + <text/> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let doc = Document::parse_str("<doc>Hello</doc>").unwrap(); + let result = validate(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_interleave_any_order() { + let schema_xml = r#" + <element name="doc" xmlns="http://relaxng.org/ns/structure/1.0"> + <interleave> + <element name="a"><text/></element> + <element name="b"><text/></element> + </interleave> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + + // Order 1: a then b + let doc1 = Document::parse_str("<doc><a>1</a><b>2</b></doc>").unwrap(); + let r1 = validate(&doc1, &schema); + assert!(r1.is_valid, "a,b order failed: {:?}", r1.errors); + + // Order 2: b then a + let doc2 = Document::parse_str("<doc><b>2</b><a>1</a></doc>").unwrap(); + let r2 = validate(&doc2, &schema); + assert!(r2.is_valid, "b,a order failed: {:?}", r2.errors); + } + + #[test] + fn test_validate_ref_define_resolution() { + let schema_xml = r#" + <grammar xmlns="http://relaxng.org/ns/structure/1.0"> + <start> + <ref name="root"/> + </start> + <define name="root"> + <element name="root"> + <ref name="content"/> + </element> + </define> + <define name="content"> + <element name="child"><text/></element> + </define> + </grammar> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let doc = Document::parse_str("<root><child>hello</child></root>").unwrap(); + let result = validate(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_nested_elements() { + let schema_xml = r#" + <element name="root" xmlns="http://relaxng.org/ns/structure/1.0"> + <element name="parent"> + <element name="child"> + <text/> + </element> + </element> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let doc = Document::parse_str("<root><parent><child>text</child></parent></root>").unwrap(); + let result = validate(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_value_match() { + let schema_xml = r#" + <element name="status" xmlns="http://relaxng.org/ns/structure/1.0"> + <value>active</value> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + + let doc1 = Document::parse_str("<status>active</status>").unwrap(); + let r1 = validate(&doc1, &schema); + assert!(r1.is_valid, "errors: {:?}", r1.errors); + + let doc2 = Document::parse_str("<status>inactive</status>").unwrap(); + let r2 = validate(&doc2, &schema); + assert!(!r2.is_valid); + } + + #[test] + fn test_validate_missing_element() { + let schema_xml = r#" + <element name="root" xmlns="http://relaxng.org/ns/structure/1.0"> + <element name="required"><text/></element> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let doc = Document::parse_str("<root/>").unwrap(); + let result = validate(&doc, &schema); + assert!(!result.is_valid); + assert!( + result.errors.iter().any(|e| e.message.contains("missing")), + "expected missing element error, got: {:?}", + result.errors + ); + } + + #[test] + fn test_validate_unexpected_element() { + let schema_xml = r#" + <element name="root" xmlns="http://relaxng.org/ns/structure/1.0"> + <empty/> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let doc = Document::parse_str("<root><surprise>oops</surprise></root>").unwrap(); + let result = validate(&doc, &schema); + assert!(!result.is_valid); + } + + #[test] + fn test_validate_empty_element() { + let schema_xml = r#" + <element name="br" xmlns="http://relaxng.org/ns/structure/1.0"> + <empty/> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let doc = Document::parse_str("<br/>").unwrap(); + let result = validate(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_attribute_value_mismatch() { + let schema_xml = r#" + <element name="item" xmlns="http://relaxng.org/ns/structure/1.0"> + <attribute name="type"> + <value>book</value> + </attribute> + <text/> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + + // Correct value. + let doc1 = Document::parse_str(r#"<item type="book">Title</item>"#).unwrap(); + let r1 = validate(&doc1, &schema); + assert!(r1.is_valid, "errors: {:?}", r1.errors); + + // Wrong value. + let doc2 = Document::parse_str(r#"<item type="dvd">Title</item>"#).unwrap(); + let r2 = validate(&doc2, &schema); + assert!(!r2.is_valid); + } + + #[test] + fn test_validate_no_root_element() { + let schema_xml = r#" + <element name="root" xmlns="http://relaxng.org/ns/structure/1.0"> + <text/> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + + let doc = Document::new(); + let result = validate(&doc, &schema); + assert!(!result.is_valid); + assert!(result + .errors + .iter() + .any(|e| e.message.contains("no root element")),); + } + + #[test] + fn test_validate_sequence_of_elements() { + let schema_xml = r#" + <element name="root" xmlns="http://relaxng.org/ns/structure/1.0"> + <element name="first"><text/></element> + <element name="second"><text/></element> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + + let doc = Document::parse_str("<root><first>a</first><second>b</second></root>").unwrap(); + let result = validate(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_sequence_wrong_order() { + let schema_xml = r#" + <element name="root" xmlns="http://relaxng.org/ns/structure/1.0"> + <element name="first"><text/></element> + <element name="second"><text/></element> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + + let doc = Document::parse_str("<root><second>b</second><first>a</first></root>").unwrap(); + let result = validate(&doc, &schema); + assert!(!result.is_valid); + } + + #[test] + fn test_pattern_display() { + assert_eq!(Pattern::Empty.to_string(), "empty"); + assert_eq!(Pattern::Text.to_string(), "text"); + assert_eq!(Pattern::NotAllowed.to_string(), "notAllowed"); + assert_eq!( + Pattern::Element { + name: NameClass::Name { + ns: String::new(), + local: "div".to_string(), + }, + pattern: Box::new(Pattern::Empty), + } + .to_string(), + "element div" + ); + } + + #[test] + fn test_name_class_display() { + assert_eq!(NameClass::AnyName.to_string(), "*"); + assert_eq!( + NameClass::Name { + ns: String::new(), + local: "foo".to_string(), + } + .to_string(), + "foo" + ); + assert_eq!( + NameClass::Name { + ns: "http://example.com".to_string(), + local: "foo".to_string(), + } + .to_string(), + "{http://example.com}foo" + ); + } + + #[test] + fn test_schema_parse_error_display() { + let err = SchemaParseError { + message: "test error".to_string(), + }; + assert_eq!(err.to_string(), "RelaxNG schema error: test error"); + } + + #[test] + fn test_parse_complex_grammar_with_cross_refs() { + let schema_xml = r#" + <grammar xmlns="http://relaxng.org/ns/structure/1.0"> + <start> + <element name="addressBook"> + <zeroOrMore> + <ref name="cardContent"/> + </zeroOrMore> + </element> + </start> + <define name="cardContent"> + <element name="card"> + <ref name="cardFields"/> + </element> + </define> + <define name="cardFields"> + <element name="name"><text/></element> + <element name="email"><text/></element> + </define> + </grammar> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + assert!(schema.defines.contains_key("cardContent")); + assert!(schema.defines.contains_key("cardFields")); + + let doc = Document::parse_str( + "<addressBook>\ + <card><name>Alice</name><email>alice@example.com</email></card>\ + <card><name>Bob</name><email>bob@example.com</email></card>\ + </addressBook>", + ) + .unwrap(); + let result = validate(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_mixed_content() { + let schema_xml = r#" + <element name="p" xmlns="http://relaxng.org/ns/structure/1.0"> + <mixed> + <zeroOrMore> + <element name="b"><text/></element> + </zeroOrMore> + </mixed> + </element> + "#; + let schema = parse_relaxng(schema_xml).unwrap(); + let doc = Document::parse_str("<p>Hello <b>world</b> and more</p>").unwrap(); + let result = validate(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + } +} diff --git a/browser/vendor/xmloxide/src/validation/schematron.rs b/browser/vendor/xmloxide/src/validation/schematron.rs new file mode 100644 index 000000000..500ff7b80 --- /dev/null +++ b/browser/vendor/xmloxide/src/validation/schematron.rs @@ -0,0 +1,1951 @@ +//! ISO Schematron validation for XML documents. +//! +//! This module implements a subset of the ISO Schematron specification +//! (ISO/IEC 19757-3) for rule-based XML document validation. Schematron +//! schemas express constraints as `XPath` assertions evaluated against +//! selected context nodes, complementing grammar-based schemas like DTD, +//! `RelaxNG`, and XSD. +//! +//! # Architecture +//! +//! The implementation is split into three layers: +//! +//! 1. **Data model** ([`SchematronSchema`], [`SchematronPattern`], +//! [`SchematronRule`], [`SchematronCheck`]) — the parsed schema +//! representation. +//! 2. **Schema parser** ([`parse_schematron`]) — reads a Schematron XML +//! schema and produces a `SchematronSchema`. +//! 3. **Validator** ([`validate_schematron`], [`validate_schematron_with_phase`]) +//! — evaluates assertions against a document tree using the `XPath` engine. +//! +//! # Examples +//! +//! ``` +//! use xmloxide::Document; +//! use xmloxide::validation::schematron::{parse_schematron, validate_schematron}; +//! +//! let schema_xml = r#" +//! <schema xmlns="http://purl.oclc.org/dml/schematron"> +//! <pattern> +//! <rule context="/root"> +//! <assert test="child">root must have a child element</assert> +//! </rule> +//! </pattern> +//! </schema> +//! "#; +//! +//! let schema = parse_schematron(schema_xml).unwrap(); +//! let doc = Document::parse_str("<root><child/></root>").unwrap(); +//! let result = validate_schematron(&doc, &schema); +//! assert!(result.is_valid); +//! ``` +//! +//! # Limitations +//! +//! - Namespace-prefixed `XPath` name tests (e.g., `//inv:invoice`) do not +//! match because the `XPath` evaluator compares against local names. +//! Unprefixed names work. Workaround: use `local-name()`. +//! - Abstract patterns and `<sch:extends>` are not supported. +//! - `xsl:key` and XSLT-specific features are not supported. +//! - Variable forward references are not supported (evaluated in document order). + +use std::collections::{HashMap, HashSet}; + +use crate::tree::{Document, NodeId, NodeKind}; +use crate::validation::{ValidationError, ValidationResult}; +use crate::xpath; +use crate::xpath::eval::XPathContext; +use crate::xpath::types::{XPathError, XPathNode, XPathValue}; + +// --------------------------------------------------------------------------- +// Data model +// --------------------------------------------------------------------------- + +/// A parsed ISO Schematron schema. +/// +/// Contains patterns (each with rules and assertions), namespace bindings, +/// schema-level variables, and optional phase definitions. +#[derive(Debug, Clone)] +pub struct SchematronSchema { + /// Namespace bindings from `<sch:ns>` elements. + pub namespaces: Vec<NamespaceBinding>, + /// Schema-level `<sch:let>` variable bindings. + pub variables: Vec<LetBinding>, + /// Patterns containing rules and assertions. + pub patterns: Vec<SchematronPattern>, + /// Named phases that activate subsets of patterns. + pub phases: HashMap<String, Phase>, + /// The default phase (`defaultPhase` attribute on the root element). + pub default_phase: Option<String>, +} + +/// A namespace binding declared via `<sch:ns prefix="..." uri="..."/>`. +#[derive(Debug, Clone)] +pub struct NamespaceBinding { + /// The namespace prefix. + pub prefix: String, + /// The namespace URI. + pub uri: String, +} + +/// A `let` variable binding declared via `<sch:let name="..." value="..."/>`. +#[derive(Debug, Clone)] +pub struct LetBinding { + /// The variable name (referenced as `$name` in `XPath` expressions). + pub name: String, + /// The `XPath` expression whose result is bound to the variable. + pub value: String, +} + +/// A pattern containing rules, with optional id and pattern-level variables. +#[derive(Debug, Clone)] +pub struct SchematronPattern { + /// Optional pattern identifier (used by phases to activate subsets). + pub id: Option<String>, + /// Whether this is an abstract pattern (template for `is-a` instantiation). + pub is_abstract: bool, + /// Reference to an abstract pattern id (instantiates the abstract pattern + /// with parameter substitutions). + pub is_a: Option<String>, + /// Parameter bindings for `is-a` instantiation (`<sch:param>`). + pub params: Vec<(String, String)>, + /// Pattern-level `<sch:let>` variable bindings. + pub variables: Vec<LetBinding>, + /// Rules within this pattern. + pub rules: Vec<SchematronRule>, +} + +/// A rule that selects context nodes and applies checks to them. +#[derive(Debug, Clone)] +pub struct SchematronRule { + /// `XPath` expression selecting context nodes. + pub context: String, + /// Rule-level `<sch:let>` variable bindings. + pub variables: Vec<LetBinding>, + /// Assertions and reports to evaluate at each context node. + pub checks: Vec<SchematronCheck>, +} + +/// An individual assertion or report within a rule. +#[derive(Debug, Clone)] +pub enum SchematronCheck { + /// An assertion: if `test` evaluates to false at the context node, + /// a validation error is raised with `message`. + Assert { + /// `XPath` boolean expression. + test: String, + /// Human-readable message parts (may include `<sch:value-of>`). + message: Vec<MessagePart>, + }, + /// A report: if `test` evaluates to true at the context node, + /// a validation warning is raised with `message`. + Report { + /// `XPath` boolean expression. + test: String, + /// Human-readable message parts (may include `<sch:value-of>`). + message: Vec<MessagePart>, + }, +} + +/// A segment of a Schematron message (plain text or interpolated value). +#[derive(Debug, Clone)] +pub enum MessagePart { + /// Literal text. + Text(String), + /// An `XPath` expression whose string value is interpolated via + /// `<sch:value-of select="..."/>`. + ValueOf { + /// The `XPath` `select` expression. + select: String, + }, +} + +/// A named phase that activates a subset of patterns. +#[derive(Debug, Clone)] +pub struct Phase { + /// The phase identifier. + pub id: String, + /// Pattern ids activated by this phase. + pub active_patterns: Vec<String>, +} + +// --------------------------------------------------------------------------- +// Schematron namespace constants +// --------------------------------------------------------------------------- + +/// ISO Schematron namespace URI. +const SCH_NS_ISO: &str = "http://purl.oclc.org/dml/schematron"; + +/// Classic Schematron 1.5 namespace URI. +const SCH_NS_CLASSIC: &str = "http://www.ascc.net/xml/schematron"; + +// --------------------------------------------------------------------------- +// Schema parser +// --------------------------------------------------------------------------- + +/// Parses a Schematron schema from an XML string. +/// +/// Supports both the ISO namespace (`http://purl.oclc.org/dml/schematron`) +/// and the classic 1.5 namespace (`http://www.ascc.net/xml/schematron`). +/// The schema can also use the `sch:` prefix convention with no namespace. +/// +/// # Errors +/// +/// Returns [`ValidationError`] if the XML cannot be parsed or the schema +/// structure is invalid (e.g., a rule missing its `context` attribute). +/// +/// # Examples +/// +/// ``` +/// use xmloxide::validation::schematron::parse_schematron; +/// +/// let schema = parse_schematron(r#" +/// <schema xmlns="http://purl.oclc.org/dml/schematron"> +/// <pattern> +/// <rule context="/*"> +/// <assert test="true()">always passes</assert> +/// </rule> +/// </pattern> +/// </schema> +/// "#).unwrap(); +/// assert_eq!(schema.patterns.len(), 1); +/// ``` +pub fn parse_schematron(schema_xml: &str) -> Result<SchematronSchema, ValidationError> { + let doc = Document::parse_str(schema_xml).map_err(|e| ValidationError { + message: format!("failed to parse Schematron schema XML: {e}"), + line: None, + column: None, + })?; + + let root = doc.root_element().ok_or_else(|| ValidationError { + message: "Schematron schema has no root element".to_string(), + line: None, + column: None, + })?; + + let root_name = doc.node_name(root).unwrap_or(""); + if !is_sch_element(root_name, "schema") { + return Err(ValidationError { + message: format!("expected <schema> root element, found <{root_name}>"), + line: None, + column: None, + }); + } + + let root_ns = doc.node_namespace(root).unwrap_or(""); + let ns_mode = detect_ns_mode(root_ns, root_name); + + let default_phase = doc.attribute(root, "defaultPhase").map(String::from); + + let mut namespaces = Vec::new(); + let mut variables = Vec::new(); + let mut patterns = Vec::new(); + let mut phases = HashMap::new(); + + for child in doc.children(root) { + if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { + continue; + } + let name = doc.node_name(child).unwrap_or(""); + let child_ns = doc.node_namespace(child).unwrap_or(""); + + if !is_sch_name_in_mode(&ns_mode, child_ns) { + continue; + } + + let local = sch_local_name(name); + match local { + "ns" => { + if let (Some(prefix), Some(uri)) = + (doc.attribute(child, "prefix"), doc.attribute(child, "uri")) + { + namespaces.push(NamespaceBinding { + prefix: prefix.to_owned(), + uri: uri.to_owned(), + }); + } + } + "let" => { + if let Some(binding) = parse_let_binding(&doc, child) { + variables.push(binding); + } + } + "pattern" => { + patterns.push(parse_pattern(&doc, &ns_mode, child)?); + } + "phase" => { + if let Some(phase) = parse_phase(&doc, &ns_mode, child) { + phases.insert(phase.id.clone(), phase); + } + } + _ => {} + } + } + + // Resolve abstract pattern instantiations (is-a references) + let patterns = resolve_abstract_patterns(patterns); + + Ok(SchematronSchema { + namespaces, + variables, + patterns, + phases, + default_phase, + }) +} + +/// Resolves `is-a` references by copying rules from abstract patterns +/// and substituting `$param` placeholders in context and test expressions. +fn resolve_abstract_patterns(patterns: Vec<SchematronPattern>) -> Vec<SchematronPattern> { + // Collect abstract patterns by id (cloned so we can move patterns below) + let abstract_map: HashMap<String, SchematronPattern> = patterns + .iter() + .filter(|p| p.is_abstract) + .filter_map(|p| p.id.as_ref().map(|id| (id.clone(), p.clone()))) + .collect(); + + patterns + .into_iter() + .filter(|p| !p.is_abstract) // Exclude abstract patterns from validation + .map(|mut p| { + if let Some(ref abstract_id) = p.is_a { + if let Some(abstract_pat) = abstract_map.get(abstract_id) { + // Copy rules from abstract pattern, substituting params + p.rules = abstract_pat + .rules + .iter() + .map(|rule| substitute_rule_params(rule, &p.params)) + .collect(); + // Also inherit variables from abstract pattern + let mut combined_vars = abstract_pat.variables.clone(); + combined_vars.extend(p.variables.clone()); + p.variables = combined_vars; + } + } + p + }) + .collect() +} + +/// Substitutes `$param_name` placeholders in a rule's context and test +/// expressions with the corresponding parameter values. +fn substitute_rule_params(rule: &SchematronRule, params: &[(String, String)]) -> SchematronRule { + SchematronRule { + context: substitute_params(&rule.context, params), + variables: rule + .variables + .iter() + .map(|v| LetBinding { + name: v.name.clone(), + value: substitute_params(&v.value, params), + }) + .collect(), + checks: rule + .checks + .iter() + .map(|check| match check { + SchematronCheck::Assert { test, message } => SchematronCheck::Assert { + test: substitute_params(test, params), + message: message.clone(), + }, + SchematronCheck::Report { test, message } => SchematronCheck::Report { + test: substitute_params(test, params), + message: message.clone(), + }, + }) + .collect(), + } +} + +/// Replaces `$name` placeholders in `text` with parameter values. +fn substitute_params(text: &str, params: &[(String, String)]) -> String { + let mut result = text.to_string(); + for (name, value) in params { + let placeholder = format!("${name}"); + result = result.replace(&placeholder, value); + } + result +} + +/// Namespace detection mode for parsing Schematron elements. +#[derive(Debug, Clone)] +enum NsMode { + /// Elements are in the ISO namespace. + Iso, + /// Elements are in the classic 1.5 namespace. + Classic, + /// Elements use `sch:` prefix with no namespace (or unrecognized namespace). + Prefix, +} + +/// Detects which namespace mode to use based on the root element. +fn detect_ns_mode(ns: &str, _name: &str) -> NsMode { + match ns { + SCH_NS_ISO => NsMode::Iso, + SCH_NS_CLASSIC => NsMode::Classic, + _ => NsMode::Prefix, + } +} + +/// Checks if a given element name matches a Schematron local name. +fn is_sch_element(name: &str, local: &str) -> bool { + name == local || name == format!("sch:{local}") || name.ends_with(&format!(":{local}")) +} + +/// Checks if a child element is a Schematron element in the detected mode. +fn is_sch_name_in_mode(mode: &NsMode, ns: &str) -> bool { + match mode { + NsMode::Iso => ns == SCH_NS_ISO, + NsMode::Classic => ns == SCH_NS_CLASSIC, + NsMode::Prefix => { + // Accept sch: prefix or bare names in schema context + ns == SCH_NS_ISO || ns == SCH_NS_CLASSIC || ns.is_empty() + } + } +} + +/// Extracts the local name from a potentially prefixed element name. +fn sch_local_name(name: &str) -> &str { + name.rsplit(':').next().unwrap_or(name) +} + +/// Parses a `<sch:let>` binding. +fn parse_let_binding(doc: &Document, node: NodeId) -> Option<LetBinding> { + let name = doc.attribute(node, "name")?; + let value = doc.attribute(node, "value").unwrap_or(""); + Some(LetBinding { + name: name.to_owned(), + value: value.to_owned(), + }) +} + +/// Parses a `<sch:pattern>` element. +fn parse_pattern( + doc: &Document, + ns_mode: &NsMode, + node: NodeId, +) -> Result<SchematronPattern, ValidationError> { + let id = doc.attribute(node, "id").map(String::from); + let is_abstract = doc.attribute(node, "abstract") == Some("true"); + let is_a = doc.attribute(node, "is-a").map(String::from); + let mut variables = Vec::new(); + let mut rules = Vec::new(); + let mut params = Vec::new(); + + for child in doc.children(node) { + if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { + continue; + } + let name = doc.node_name(child).unwrap_or(""); + let child_ns = doc.node_namespace(child).unwrap_or(""); + + if !is_sch_name_in_mode(ns_mode, child_ns) { + continue; + } + + let local = sch_local_name(name); + match local { + "let" => { + if let Some(binding) = parse_let_binding(doc, child) { + variables.push(binding); + } + } + "rule" => { + rules.push(parse_rule(doc, ns_mode, child)?); + } + "param" => { + if let (Some(pname), Some(pvalue)) = + (doc.attribute(child, "name"), doc.attribute(child, "value")) + { + params.push((pname.to_owned(), pvalue.to_owned())); + } + } + _ => {} + } + } + + Ok(SchematronPattern { + id, + is_abstract, + is_a, + params, + variables, + rules, + }) +} + +/// Parses a `<sch:rule>` element. +fn parse_rule( + doc: &Document, + ns_mode: &NsMode, + node: NodeId, +) -> Result<SchematronRule, ValidationError> { + let context = doc + .attribute(node, "context") + .ok_or_else(|| ValidationError { + message: "rule element is missing required 'context' attribute".to_string(), + line: None, + column: None, + })? + .to_owned(); + + let mut variables = Vec::new(); + let mut checks = Vec::new(); + + for child in doc.children(node) { + if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { + continue; + } + let name = doc.node_name(child).unwrap_or(""); + let child_ns = doc.node_namespace(child).unwrap_or(""); + + if !is_sch_name_in_mode(ns_mode, child_ns) { + continue; + } + + let local = sch_local_name(name); + match local { + "let" => { + if let Some(binding) = parse_let_binding(doc, child) { + variables.push(binding); + } + } + "assert" => { + if let Some(check) = parse_check(doc, ns_mode, child, true) { + checks.push(check); + } + } + "report" => { + if let Some(check) = parse_check(doc, ns_mode, child, false) { + checks.push(check); + } + } + _ => {} + } + } + + Ok(SchematronRule { + context, + variables, + checks, + }) +} + +/// Parses a `<sch:assert>` or `<sch:report>` element. +fn parse_check( + doc: &Document, + ns_mode: &NsMode, + node: NodeId, + is_assert: bool, +) -> Option<SchematronCheck> { + let test = doc.attribute(node, "test")?.to_owned(); + let message = parse_message_parts(doc, ns_mode, node); + if is_assert { + Some(SchematronCheck::Assert { test, message }) + } else { + Some(SchematronCheck::Report { test, message }) + } +} + +/// Parses the mixed content of an assert/report element into message parts. +fn parse_message_parts(doc: &Document, ns_mode: &NsMode, node: NodeId) -> Vec<MessagePart> { + let mut parts = Vec::new(); + for child in doc.children(node) { + match &doc.node(child).kind { + NodeKind::Text { content } if !content.is_empty() => { + parts.push(MessagePart::Text(content.clone())); + } + NodeKind::Element { .. } => { + let name = doc.node_name(child).unwrap_or(""); + let child_ns = doc.node_namespace(child).unwrap_or(""); + if is_sch_name_in_mode(ns_mode, child_ns) && sch_local_name(name) == "value-of" { + if let Some(select) = doc.attribute(child, "select") { + parts.push(MessagePart::ValueOf { + select: select.to_owned(), + }); + } + } + } + _ => {} + } + } + parts +} + +/// Parses a `<sch:phase>` element. +fn parse_phase(doc: &Document, ns_mode: &NsMode, node: NodeId) -> Option<Phase> { + let id = doc.attribute(node, "id")?.to_owned(); + let mut active_patterns = Vec::new(); + + for child in doc.children(node) { + if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { + continue; + } + let name = doc.node_name(child).unwrap_or(""); + let child_ns = doc.node_namespace(child).unwrap_or(""); + if is_sch_name_in_mode(ns_mode, child_ns) && sch_local_name(name) == "active" { + if let Some(pattern) = doc.attribute(child, "pattern") { + active_patterns.push(pattern.to_owned()); + } + } + } + + Some(Phase { + id, + active_patterns, + }) +} + +// --------------------------------------------------------------------------- +// Validator +// --------------------------------------------------------------------------- + +/// Validates a document against a Schematron schema. +/// +/// Evaluates all patterns (or the default phase's patterns) and returns +/// a [`ValidationResult`] with errors from failed assertions and warnings +/// from fired reports. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::Document; +/// use xmloxide::validation::schematron::{parse_schematron, validate_schematron}; +/// +/// let schema = parse_schematron(r#" +/// <schema xmlns="http://purl.oclc.org/dml/schematron"> +/// <pattern> +/// <rule context="/root"> +/// <assert test="child">root must have a child element</assert> +/// </rule> +/// </pattern> +/// </schema> +/// "#).unwrap(); +/// +/// let doc = Document::parse_str("<root><child/></root>").unwrap(); +/// let result = validate_schematron(&doc, &schema); +/// assert!(result.is_valid); +/// ``` +pub fn validate_schematron(doc: &Document, schema: &SchematronSchema) -> ValidationResult { + if let Some(ref phase_id) = schema.default_phase { + validate_schematron_with_phase(doc, schema, phase_id) + } else { + validate_patterns(doc, schema, &schema.patterns) + } +} + +/// Validates a document against a Schematron schema using a specific phase. +/// +/// Only patterns referenced by `<sch:active>` elements within the named +/// phase are evaluated. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::Document; +/// use xmloxide::validation::schematron::{parse_schematron, validate_schematron_with_phase}; +/// +/// let schema = parse_schematron(r#" +/// <schema xmlns="http://purl.oclc.org/dml/schematron"> +/// <phase id="quick"> +/// <active pattern="basic"/> +/// </phase> +/// <pattern id="basic"> +/// <rule context="/*"> +/// <assert test="true()">always passes</assert> +/// </rule> +/// </pattern> +/// <pattern id="strict"> +/// <rule context="/*"> +/// <assert test="false()">always fails</assert> +/// </rule> +/// </pattern> +/// </schema> +/// "#).unwrap(); +/// +/// let doc = Document::parse_str("<root/>").unwrap(); +/// let result = validate_schematron_with_phase(&doc, &schema, "quick"); +/// assert!(result.is_valid); +/// ``` +pub fn validate_schematron_with_phase( + doc: &Document, + schema: &SchematronSchema, + phase_id: &str, +) -> ValidationResult { + if let Some(phase) = schema.phases.get(phase_id) { + let active_ids: HashSet<&str> = phase.active_patterns.iter().map(String::as_str).collect(); + let active_patterns: Vec<&SchematronPattern> = schema + .patterns + .iter() + .filter(|p| { + p.id.as_ref() + .is_some_and(|id| active_ids.contains(id.as_str())) + }) + .collect(); + validate_pattern_refs(doc, schema, &active_patterns) + } else { + // Unknown phase — validate all patterns + validate_patterns(doc, schema, &schema.patterns) + } +} + +/// Validates a set of patterns (owned references). +fn validate_patterns( + doc: &Document, + schema: &SchematronSchema, + patterns: &[SchematronPattern], +) -> ValidationResult { + let refs: Vec<&SchematronPattern> = patterns.iter().collect(); + validate_pattern_refs(doc, schema, &refs) +} + +/// Core validation logic operating on a slice of pattern references. +fn validate_pattern_refs( + doc: &Document, + schema: &SchematronSchema, + patterns: &[&SchematronPattern], +) -> ValidationResult { + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + + let root = doc.root(); + let ns = &schema.namespaces; + + // Evaluate schema-level variables at the document root + let root_node = XPathNode::Node(root); + let schema_vars = eval_variables(doc, root_node, &schema.variables, &HashMap::new(), ns); + + for pattern in patterns { + // Per-pattern fired_nodes tracking (firing rule semantics) + let mut fired_nodes: HashSet<XPathNode> = HashSet::new(); + + // Evaluate pattern-level variables + let mut pattern_vars = schema_vars.clone(); + let extra = eval_variables(doc, root_node, &pattern.variables, &pattern_vars, ns); + pattern_vars.extend(extra); + + for rule in &pattern.rules { + // Evaluate the context XPath to find matching nodes + let context_nodes = + match eval_context_xpath(doc, root, &rule.context, &pattern_vars, ns) { + Ok(nodes) => nodes, + Err(e) => { + errors.push(ValidationError { + message: format!( + "XPath error in rule context '{}': {}", + rule.context, e + ), + line: None, + column: None, + }); + continue; + } + }; + + for &node in &context_nodes { + // Firing rule: skip nodes already fired in this pattern + if fired_nodes.contains(&node) { + continue; + } + fired_nodes.insert(node); + + // Evaluate rule-level variables at this context node + let mut rule_vars = pattern_vars.clone(); + let extra = eval_variables(doc, node, &rule.variables, &rule_vars, ns); + rule_vars.extend(extra); + + for check in &rule.checks { + match check { + SchematronCheck::Assert { test, message } => { + match eval_test(doc, node, test, &rule_vars, ns) { + Ok(true) => {} // assertion satisfied + Ok(false) => { + let msg = + interpolate_message(doc, node, message, &rule_vars, ns); + errors.push(ValidationError { + message: msg, + line: None, + column: None, + }); + } + Err(e) => { + errors.push(ValidationError { + message: format!( + "XPath error in assert test '{test}': {e}" + ), + line: None, + column: None, + }); + } + } + } + SchematronCheck::Report { test, message } => { + match eval_test(doc, node, test, &rule_vars, ns) { + Ok(true) => { + let msg = + interpolate_message(doc, node, message, &rule_vars, ns); + warnings.push(ValidationError { + message: msg, + line: None, + column: None, + }); + } + Ok(false) => {} // report condition not met + Err(e) => { + errors.push(ValidationError { + message: format!( + "XPath error in report test '{test}': {e}" + ), + line: None, + column: None, + }); + } + } + } + } + } + } + } + } + + ValidationResult { + is_valid: errors.is_empty(), + errors, + warnings, + } +} + +/// Creates an `XPathContext` with variables and namespace bindings. +/// +/// The context node may be an attribute node (e.g., for rules whose +/// context expression selects attributes). +fn make_xpath_context<'a>( + doc: &'a Document, + node: XPathNode, + variables: &HashMap<String, XPathValue>, + ns_bindings: &[NamespaceBinding], +) -> XPathContext<'a> { + let mut ctx = XPathContext::new_at(doc, node); + for (name, value) in variables { + ctx.set_variable(name, value.clone()); + } + for ns in ns_bindings { + ctx.set_namespace(&ns.prefix, &ns.uri); + } + ctx +} + +/// Evaluates an `XPath` context expression and returns the matching nodes. +/// +/// The result may contain attribute nodes: a rule whose context selects +/// attributes fires with the attribute itself as the context node. +fn eval_context_xpath( + doc: &Document, + root: NodeId, + xpath_expr: &str, + variables: &HashMap<String, XPathValue>, + ns_bindings: &[NamespaceBinding], +) -> Result<Vec<XPathNode>, XPathError> { + let expr = xpath::parser::parse(xpath_expr)?; + let ctx = make_xpath_context(doc, XPathNode::Node(root), variables, ns_bindings); + let result = ctx.evaluate(&expr)?; + match result { + XPathValue::NodeSet(nodes) => Ok(nodes), + _ => Ok(vec![]), + } +} + +/// Evaluates a test expression at a context node, returning a boolean. +fn eval_test( + doc: &Document, + node: XPathNode, + test_expr: &str, + variables: &HashMap<String, XPathValue>, + ns_bindings: &[NamespaceBinding], +) -> Result<bool, XPathError> { + let expr = xpath::parser::parse(test_expr)?; + let ctx = make_xpath_context(doc, node, variables, ns_bindings); + let result = ctx.evaluate(&expr)?; + Ok(result.to_boolean()) +} + +/// Evaluates `<sch:let>` bindings and returns the resulting variable map. +fn eval_variables( + doc: &Document, + context_node: XPathNode, + bindings: &[LetBinding], + existing: &HashMap<String, XPathValue>, + ns_bindings: &[NamespaceBinding], +) -> HashMap<String, XPathValue> { + let mut result = HashMap::new(); + // Accumulate so later bindings can reference earlier ones + let mut combined = existing.clone(); + + for binding in bindings { + // Try to evaluate as XPath; fall back to string literal + let value = if let Ok(expr) = xpath::parser::parse(&binding.value) { + let ctx = make_xpath_context(doc, context_node, &combined, ns_bindings); + ctx.evaluate(&expr) + .unwrap_or_else(|_| XPathValue::String(binding.value.clone())) + } else { + XPathValue::String(binding.value.clone()) + }; + + result.insert(binding.name.clone(), value.clone()); + combined.insert(binding.name.clone(), value); + } + + result +} + +/// Interpolates message parts by evaluating `<sch:value-of>` expressions. +fn interpolate_message( + doc: &Document, + node: XPathNode, + parts: &[MessagePart], + variables: &HashMap<String, XPathValue>, + ns_bindings: &[NamespaceBinding], +) -> String { + let mut result = String::new(); + for part in parts { + match part { + MessagePart::Text(text) => result.push_str(text), + MessagePart::ValueOf { select } => { + if let Ok(expr) = xpath::parser::parse(select) { + let ctx = make_xpath_context(doc, node, variables, ns_bindings); + if let Ok(val) = ctx.evaluate(&expr) { + result.push_str(&xpath_value_to_string(doc, &val)); + } + } + } + } + } + result +} + +/// Converts an `XPath` value to a string, computing string-value for +/// node-sets using the document (unlike `to_xpath_string()` which returns +/// empty for node-sets without document access). +fn xpath_value_to_string(doc: &Document, val: &XPathValue) -> String { + match val { + XPathValue::NodeSet(nodes) => match nodes.first() { + Some(&XPathNode::Attribute { owner, index }) => doc + .attributes(owner) + .get(index as usize) + .map(|a| a.value.clone()) + .unwrap_or_default(), + Some(&XPathNode::Node(id)) => doc.text_content(id), + None => String::new(), + }, + _ => val.to_xpath_string(), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + // =================================================================== + // Phase 1: Parsing tests + // =================================================================== + + #[test] + fn test_parse_minimal_schema() { + let schema = + parse_schematron(r#"<schema xmlns="http://purl.oclc.org/dml/schematron"/>"#).unwrap(); + assert!(schema.patterns.is_empty()); + assert!(schema.namespaces.is_empty()); + assert!(schema.variables.is_empty()); + assert!(schema.phases.is_empty()); + assert!(schema.default_phase.is_none()); + } + + #[test] + fn test_parse_single_assert() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="/root"> + <assert test="@id">root must have an id</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + assert_eq!(schema.patterns.len(), 1); + assert_eq!(schema.patterns[0].rules.len(), 1); + assert_eq!(schema.patterns[0].rules[0].context, "/root"); + assert_eq!(schema.patterns[0].rules[0].checks.len(), 1); + match &schema.patterns[0].rules[0].checks[0] { + SchematronCheck::Assert { test, message } => { + assert_eq!(test, "@id"); + assert_eq!(message.len(), 1); + match &message[0] { + MessagePart::Text(t) => assert_eq!(t, "root must have an id"), + MessagePart::ValueOf { .. } => panic!("expected Text message part"), + } + } + SchematronCheck::Report { .. } => panic!("expected Assert check"), + } + } + + #[test] + fn test_parse_report() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="//item"> + <report test="@deprecated">item is deprecated</report> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + match &schema.patterns[0].rules[0].checks[0] { + SchematronCheck::Report { test, message } => { + assert_eq!(test, "@deprecated"); + assert_eq!(message.len(), 1); + } + SchematronCheck::Assert { .. } => panic!("expected Report check"), + } + } + + #[test] + fn test_parse_multiple_patterns() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern id="p1"> + <rule context="/a"> + <assert test="b">need b</assert> + </rule> + </pattern> + <pattern id="p2"> + <rule context="/a"> + <assert test="c">need c</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + assert_eq!(schema.patterns.len(), 2); + assert_eq!(schema.patterns[0].id.as_deref(), Some("p1")); + assert_eq!(schema.patterns[1].id.as_deref(), Some("p2")); + } + + #[test] + fn test_parse_ns_bindings() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <ns prefix="inv" uri="urn:invoice"/> + <ns prefix="cbc" uri="urn:oasis:names:cbc"/> + </schema> + "#, + ) + .unwrap(); + assert_eq!(schema.namespaces.len(), 2); + assert_eq!(schema.namespaces[0].prefix, "inv"); + assert_eq!(schema.namespaces[0].uri, "urn:invoice"); + assert_eq!(schema.namespaces[1].prefix, "cbc"); + assert_eq!(schema.namespaces[1].uri, "urn:oasis:names:cbc"); + } + + #[test] + fn test_parse_let_bindings() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <let name="threshold" value="100"/> + <pattern> + <let name="pat_var" value="'hello'"/> + <rule context="/*"> + <let name="rule_var" value="@count"/> + <assert test="$rule_var > $threshold">too low</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + assert_eq!(schema.variables.len(), 1); + assert_eq!(schema.variables[0].name, "threshold"); + assert_eq!(schema.variables[0].value, "100"); + assert_eq!(schema.patterns[0].variables.len(), 1); + assert_eq!(schema.patterns[0].variables[0].name, "pat_var"); + assert_eq!(schema.patterns[0].rules[0].variables.len(), 1); + assert_eq!(schema.patterns[0].rules[0].variables[0].name, "rule_var"); + } + + #[test] + fn test_parse_value_of_in_message() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="/root"> + <assert test="@id">element <value-of select="name()"/> must have an id</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + let check = &schema.patterns[0].rules[0].checks[0]; + match check { + SchematronCheck::Assert { message, .. } => { + assert_eq!(message.len(), 3); + match &message[0] { + MessagePart::Text(t) => assert_eq!(t, "element "), + MessagePart::ValueOf { .. } => panic!("expected Text"), + } + match &message[1] { + MessagePart::ValueOf { select } => assert_eq!(select, "name()"), + MessagePart::Text(_) => panic!("expected ValueOf"), + } + match &message[2] { + MessagePart::Text(t) => assert_eq!(t, " must have an id"), + MessagePart::ValueOf { .. } => panic!("expected Text"), + } + } + SchematronCheck::Report { .. } => panic!("expected Assert"), + } + } + + #[test] + fn test_parse_error_missing_context() { + let result = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule> + <assert test="true()">ok</assert> + </rule> + </pattern> + </schema> + "#, + ); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.message.contains("context"), + "error should mention 'context': {}", + err.message + ); + } + + // =================================================================== + // Phase 2: Basic validation tests + // =================================================================== + + #[test] + fn test_validate_assert_passes() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="/root"> + <assert test="child">root must have a child</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + let doc = Document::parse_str("<root><child/></root>").unwrap(); + let result = validate_schematron(&doc, &schema); + assert!(result.is_valid); + assert!(result.errors.is_empty()); + } + + #[test] + fn test_validate_assert_fails() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="/root"> + <assert test="child">root must have a child</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + let doc = Document::parse_str("<root/>").unwrap(); + let result = validate_schematron(&doc, &schema); + assert!(!result.is_valid); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].message, "root must have a child"); + } + + #[test] + fn test_validate_report_fires() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="/root"> + <report test="@deprecated">element is deprecated</report> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + let doc = Document::parse_str(r#"<root deprecated="true"/>"#).unwrap(); + let result = validate_schematron(&doc, &schema); + // Reports produce warnings, not errors + assert!(result.is_valid); + assert_eq!(result.warnings.len(), 1); + assert_eq!(result.warnings[0].message, "element is deprecated"); + } + + #[test] + fn test_validate_report_silent() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="/root"> + <report test="@deprecated">element is deprecated</report> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + let doc = Document::parse_str("<root/>").unwrap(); + let result = validate_schematron(&doc, &schema); + assert!(result.is_valid); + assert!(result.warnings.is_empty()); + } + + #[test] + fn test_validate_multiple_asserts() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="/root"> + <assert test="@id">must have id</assert> + <assert test="child">must have child</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + let doc = Document::parse_str("<root/>").unwrap(); + let result = validate_schematron(&doc, &schema); + assert!(!result.is_valid); + assert_eq!(result.errors.len(), 2); + } + + #[test] + fn test_validate_context_multiple_nodes() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="//item"> + <assert test="@name">item must have name</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + let doc = + Document::parse_str(r#"<root><item name="a"/><item/><item name="c"/></root>"#).unwrap(); + let result = validate_schematron(&doc, &schema); + assert!(!result.is_valid); + // Only the second <item> lacks @name + assert_eq!(result.errors.len(), 1); + } + + #[test] + fn test_validate_no_matching_nodes() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="//nonexistent"> + <assert test="false()">should never fire</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + let doc = Document::parse_str("<root/>").unwrap(); + let result = validate_schematron(&doc, &schema); + assert!(result.is_valid); + } + + #[test] + fn test_validate_multiple_patterns() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="/root"> + <assert test="@id">need id</assert> + </rule> + </pattern> + <pattern> + <rule context="/root"> + <assert test="child">need child</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + let doc = Document::parse_str("<root/>").unwrap(); + let result = validate_schematron(&doc, &schema); + assert!(!result.is_valid); + // Both patterns fire on the same node (different patterns = independent) + assert_eq!(result.errors.len(), 2); + } + + // =================================================================== + // Phase 3: Firing rules tests + // =================================================================== + + #[test] + fn test_firing_rule_first_wins() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="/root"> + <assert test="true()">first rule passes</assert> + </rule> + <rule context="/root"> + <assert test="false()">second rule would fail</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + let doc = Document::parse_str("<root/>").unwrap(); + let result = validate_schematron(&doc, &schema); + // The second rule never fires because /root already fired in rule 1 + assert!(result.is_valid); + } + + #[test] + fn test_firing_rule_across_patterns() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="/root"> + <assert test="true()">pattern 1 passes</assert> + </rule> + </pattern> + <pattern> + <rule context="/root"> + <assert test="false()">pattern 2 fails</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + let doc = Document::parse_str("<root/>").unwrap(); + let result = validate_schematron(&doc, &schema); + // Same node fires independently in each pattern + assert!(!result.is_valid); + assert_eq!(result.errors.len(), 1); + } + + // =================================================================== + // Phase 4: Variables tests + // =================================================================== + + #[test] + fn test_variable_schema_level() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <let name="threshold" value="100"/> + <pattern> + <rule context="/root"> + <assert test="@count >= $threshold">count must be at least 100</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + + let doc_pass = Document::parse_str(r#"<root count="150"/>"#).unwrap(); + assert!(validate_schematron(&doc_pass, &schema).is_valid); + + let doc_fail = Document::parse_str(r#"<root count="50"/>"#).unwrap(); + assert!(!validate_schematron(&doc_fail, &schema).is_valid); + } + + #[test] + fn test_variable_rule_level() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="/root"> + <let name="n" value="@name"/> + <assert test="string-length($n) > 0">name must not be empty</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + + let doc_pass = Document::parse_str(r#"<root name="hello"/>"#).unwrap(); + assert!(validate_schematron(&doc_pass, &schema).is_valid); + + let doc_fail = Document::parse_str(r#"<root name=""/>"#).unwrap(); + assert!(!validate_schematron(&doc_fail, &schema).is_valid); + } + + #[test] + fn test_variable_xpath_expression() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="/root"> + <let name="total" value="count(item)"/> + <assert test="$total > 0">must have at least one item</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + + let doc_pass = Document::parse_str("<root><item/><item/></root>").unwrap(); + assert!(validate_schematron(&doc_pass, &schema).is_valid); + + let doc_fail = Document::parse_str("<root/>").unwrap(); + assert!(!validate_schematron(&doc_fail, &schema).is_valid); + } + + // =================================================================== + // Phase 5: Message interpolation tests + // =================================================================== + + #[test] + fn test_message_value_of() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="/root"> + <assert test="false()">element <value-of select="name()"/> failed</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + let doc = Document::parse_str("<root/>").unwrap(); + let result = validate_schematron(&doc, &schema); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].message, "element root failed"); + } + + #[test] + fn test_message_mixed() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="/order"> + <assert test="false()">Order <value-of select="@id"/> has <value-of select="count(item)"/> items</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + let doc = Document::parse_str(r#"<order id="42"><item/><item/><item/></order>"#).unwrap(); + let result = validate_schematron(&doc, &schema); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].message, "Order 42 has 3 items"); + } + + #[test] + fn test_attribute_rule_context() { + // A rule context selecting attribute nodes evaluates its asserts + // with the ATTRIBUTE as the context node: `.` is the attribute + // value, not the owner element's text content. + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="//@id"> + <assert test=". = 'x'">id must be x (got <value-of select="."/>)</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + // Valid: the id attribute IS 'x' even though the element text differs. + let doc = Document::parse_str(r#"<r><a id="x">element text</a></r>"#).unwrap(); + let result = validate_schematron(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + + // Invalid: the assert fails and value-of reads the attribute value. + let doc2 = Document::parse_str(r#"<r><a id="y">element text</a></r>"#).unwrap(); + let result2 = validate_schematron(&doc2, &schema); + assert!(!result2.is_valid); + assert_eq!(result2.errors[0].message, "id must be x (got y)"); + } + + #[test] + fn test_attribute_rule_context_does_not_mask_element_rules() { + // Firing an attribute-context rule must not mark the owner ELEMENT + // as fired for later rules in the same pattern. + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="//item/@code"> + <assert test="string-length(.) >= 3">code too short</assert> + </rule> + <rule context="//item"> + <assert test="@name">item must have a name attribute</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + // code is fine but @name is missing: exactly the element rule fires. + let doc = Document::parse_str(r#"<order><item code="XYZ"/></order>"#).unwrap(); + let result = validate_schematron(&doc, &schema); + assert_eq!(result.errors.len(), 1, "errors: {:?}", result.errors); + assert_eq!(result.errors[0].message, "item must have a name attribute"); + } + + // =================================================================== + // Phase 6: Phases + integration tests + // =================================================================== + + #[test] + fn test_phase_selective() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron" defaultPhase="quick"> + <phase id="quick"> + <active pattern="basic"/> + </phase> + <pattern id="basic"> + <rule context="/*"> + <assert test="true()">basic passes</assert> + </rule> + </pattern> + <pattern id="strict"> + <rule context="/*"> + <assert test="false()">strict fails</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + let doc = Document::parse_str("<root/>").unwrap(); + + // Default phase is "quick", which only activates "basic" + let result = validate_schematron(&doc, &schema); + assert!(result.is_valid); + + let schema2 = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <phase id="quick"> + <active pattern="basic"/> + </phase> + <phase id="full"> + <active pattern="basic"/> + <active pattern="strict"/> + </phase> + <pattern id="basic"> + <rule context="/*"> + <assert test="true()">basic passes</assert> + </rule> + </pattern> + <pattern id="strict"> + <rule context="/*"> + <assert test="false()">strict fails</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + + let quick = validate_schematron_with_phase(&doc, &schema2, "quick"); + assert!(quick.is_valid); + + let full = validate_schematron_with_phase(&doc, &schema2, "full"); + assert!(!full.is_valid); + assert_eq!(full.errors.len(), 1); + } + + #[test] + fn test_validate_invoice_schema() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <let name="min_items" value="1"/> + <pattern id="structure"> + <rule context="/invoice"> + <assert test="@id">Invoice must have an id</assert> + <assert test="customer">Invoice must have a customer</assert> + <assert test="count(item) >= $min_items">Invoice must have at least <value-of select="$min_items"/> item(s)</assert> + </rule> + </pattern> + <pattern id="amounts"> + <rule context="//item"> + <assert test="number(@amount) > 0">Item <value-of select="@name"/> amount must be positive</assert> + </rule> + </pattern> + <pattern id="names"> + <rule context="//item"> + <assert test="@name">Every item must have a name</assert> + <report test="@discount">Item <value-of select="@name"/> has a discount applied</report> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + + // Valid invoice + let valid_doc = Document::parse_str( + r#"<invoice id="INV-001"> + <customer>Acme Corp</customer> + <item name="Widget" amount="10"/> + <item name="Gadget" amount="20"/> + </invoice>"#, + ) + .unwrap(); + let result = validate_schematron(&valid_doc, &schema); + assert!( + result.is_valid, + "valid invoice should pass: {:?}", + result.errors + ); + + // Invalid: missing id, no customer, zero amount + let invalid_doc = Document::parse_str( + r#"<invoice> + <item name="Widget" amount="10"/> + <item name="Gadget" amount="0"/> + </invoice>"#, + ) + .unwrap(); + let result = validate_schematron(&invalid_doc, &schema); + assert!(!result.is_valid); + // Errors: missing id, missing customer, zero amount on Gadget + assert!( + result.errors.len() >= 3, + "expected at least 3 errors, got {}: {:?}", + result.errors.len(), + result.errors + ); + + // Test report fires on discount attribute + let discount_doc = Document::parse_str( + r#"<invoice id="INV-002"> + <customer>Beta Corp</customer> + <item name="Widget" amount="10" discount="5"/> + </invoice>"#, + ) + .unwrap(); + let result = validate_schematron(&discount_doc, &schema); + assert!(result.is_valid); + assert_eq!(result.warnings.len(), 1); + assert_eq!( + result.warnings[0].message, + "Item Widget has a discount applied" + ); + } + + #[test] + fn test_xpath_error_recovery() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="/root"> + <assert test="[[[invalid xpath">should not crash</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + let doc = Document::parse_str("<root/>").unwrap(); + let result = validate_schematron(&doc, &schema); + // Should report an error about XPath, not panic + assert!(!result.is_valid); + assert!( + result.errors[0].message.contains("XPath error"), + "error should mention XPath: {}", + result.errors[0].message + ); + } + + #[test] + fn test_validate_sum_attribute_path() { + // Tests that sum(child/@attr) works correctly now that + // attribute paths return proper NodeSets. + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="/order"> + <let name="total" value="sum(item/@price)"/> + <assert test="$total = @expected">Total <value-of select="$total"/> does not match expected <value-of select="@expected"/></assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + + let doc_pass = Document::parse_str( + r#"<order expected="30"><item price="10"/><item price="20"/></order>"#, + ) + .unwrap(); + let result = validate_schematron(&doc_pass, &schema); + assert!(result.is_valid, "sum should equal 30: {:?}", result.errors); + + let doc_fail = Document::parse_str( + r#"<order expected="99"><item price="10"/><item price="20"/></order>"#, + ) + .unwrap(); + let result = validate_schematron(&doc_fail, &schema); + assert!(!result.is_valid); + } + + // =================================================================== + // Namespace-prefixed XPath tests + // =================================================================== + + #[test] + fn test_namespace_prefixed_xpath() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <ns prefix="inv" uri="urn:example:invoice"/> + <pattern> + <rule context="/inv:invoice"> + <assert test="inv:customer">Invoice must have a customer</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + + // Document with namespace + let doc_pass = Document::parse_str( + r#"<invoice xmlns="urn:example:invoice"><customer>Acme</customer></invoice>"#, + ) + .unwrap(); + let result = validate_schematron(&doc_pass, &schema); + assert!( + result.is_valid, + "namespace-prefixed XPath should match: {:?}", + result.errors + ); + + // Document with namespace but missing customer + let doc_fail = Document::parse_str(r#"<invoice xmlns="urn:example:invoice"/>"#).unwrap(); + let result = validate_schematron(&doc_fail, &schema); + assert!(!result.is_valid); + assert_eq!(result.errors.len(), 1); + } + + #[test] + fn test_namespace_prefix_wildcard() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <ns prefix="inv" uri="urn:example:invoice"/> + <pattern> + <rule context="/inv:*"> + <assert test="@id">Root element must have an id</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + + let doc = Document::parse_str(r#"<invoice xmlns="urn:example:invoice" id="1"/>"#).unwrap(); + let result = validate_schematron(&doc, &schema); + assert!(result.is_valid); + + let doc_fail = Document::parse_str(r#"<invoice xmlns="urn:example:invoice"/>"#).unwrap(); + let result = validate_schematron(&doc_fail, &schema); + assert!(!result.is_valid); + } + + // =================================================================== + // Additional edge case tests + // =================================================================== + + #[test] + fn test_matches_function() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern> + <rule context="/order"> + <assert test="matches(@country, '[A-Z]{2}')">Country must be a 2-letter ISO code</assert> + <assert test="matches(@id, '[A-Z]+-\d+')">ID must match format LETTERS-DIGITS</assert> + </rule> + </pattern> + </schema> + "#, + ) + .unwrap(); + + let doc_pass = Document::parse_str(r#"<order country="US" id="INV-42"/>"#).unwrap(); + assert!(validate_schematron(&doc_pass, &schema).is_valid); + + let doc_fail = Document::parse_str(r#"<order country="usa" id="123"/>"#).unwrap(); + let result = validate_schematron(&doc_fail, &schema); + assert!(!result.is_valid); + assert_eq!(result.errors.len(), 2); + } + + #[test] + fn test_classic_namespace() { + let schema = parse_schematron( + r#"<schema xmlns="http://www.ascc.net/xml/schematron"> + <pattern> + <rule context="/*"> + <assert test="true()">ok</assert> + </rule> + </pattern> + </schema>"#, + ) + .unwrap(); + assert_eq!(schema.patterns.len(), 1); + let doc = Document::parse_str("<root/>").unwrap(); + assert!(validate_schematron(&doc, &schema).is_valid); + } + + #[test] + fn test_prefixed_schema() { + let schema = parse_schematron( + r#"<sch:schema xmlns:sch="http://purl.oclc.org/dml/schematron"> + <sch:pattern> + <sch:rule context="/*"> + <sch:assert test="true()">ok</sch:assert> + </sch:rule> + </sch:pattern> + </sch:schema>"#, + ) + .unwrap(); + assert_eq!(schema.patterns.len(), 1); + } + + // =================================================================== + // Abstract pattern tests + // =================================================================== + + #[test] + fn test_abstract_pattern_basic() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern id="req_attr" abstract="true"> + <rule context="$element"> + <assert test="@$attr">Element must have $attr attribute</assert> + </rule> + </pattern> + <pattern id="check_id" is-a="req_attr"> + <param name="element" value="//item"/> + <param name="attr" value="id"/> + </pattern> + <pattern id="check_name" is-a="req_attr"> + <param name="element" value="//item"/> + <param name="attr" value="name"/> + </pattern> + </schema> + "#, + ) + .unwrap(); + + // Abstract pattern should be excluded, two concrete patterns remain + assert_eq!(schema.patterns.len(), 2); + + // First pattern should have context "//item" and test "@id" + assert_eq!(schema.patterns[0].rules[0].context, "//item"); + match &schema.patterns[0].rules[0].checks[0] { + SchematronCheck::Assert { test, .. } => assert_eq!(test, "@id"), + SchematronCheck::Report { .. } => panic!("expected assert"), + } + + // Validate + let doc_pass = Document::parse_str(r#"<root><item id="1" name="x"/></root>"#).unwrap(); + assert!(validate_schematron(&doc_pass, &schema).is_valid); + + let doc_fail = Document::parse_str(r#"<root><item id="1"/></root>"#).unwrap(); + let result = validate_schematron(&doc_fail, &schema); + assert!(!result.is_valid); + // Missing name attribute + assert_eq!(result.errors.len(), 1); + } + + #[test] + fn test_abstract_pattern_multiple_rules() { + let schema = parse_schematron( + r#" + <schema xmlns="http://purl.oclc.org/dml/schematron"> + <pattern id="has_content" abstract="true"> + <rule context="$ctx"> + <assert test="string-length(normalize-space(.)) > 0">$ctx must not be empty</assert> + </rule> + </pattern> + <pattern is-a="has_content"> + <param name="ctx" value="/doc/title"/> + </pattern> + <pattern is-a="has_content"> + <param name="ctx" value="/doc/body"/> + </pattern> + </schema> + "#, + ) + .unwrap(); + + let doc_pass = + Document::parse_str("<doc><title>Hi</title><body>Content</body></doc>").unwrap(); + assert!(validate_schematron(&doc_pass, &schema).is_valid); + + let doc_fail = Document::parse_str("<doc><title>Hi</title><body> </body></doc>").unwrap(); + assert!(!validate_schematron(&doc_fail, &schema).is_valid); + } +} diff --git a/browser/vendor/xmloxide/src/validation/xsd.rs b/browser/vendor/xmloxide/src/validation/xsd.rs new file mode 100644 index 000000000..fb0bc0d1b --- /dev/null +++ b/browser/vendor/xmloxide/src/validation/xsd.rs @@ -0,0 +1,3731 @@ +//! XML Schema (XSD 1.0) validation for XML documents. +//! +//! This module implements a subset of the W3C XML Schema Definition Language +//! (XSD) 1.0 specification (<https://www.w3.org/TR/xmlschema-1/>) for +//! validating XML documents against XSD schemas. +//! +//! # Supported Features +//! +//! - Global and local element declarations with type references or inline types +//! - Complex types with `sequence`, `choice`, `all`, and empty content models +//! - Simple types with restriction facets, list, and union varieties +//! - Built-in XSD datatypes (string, integer, boolean, date, etc.) +//! - Attribute declarations with required/optional, default, and fixed values +//! - Occurrence constraints (`minOccurs`, `maxOccurs`) +//! - Mixed content +//! - Attribute groups +//! - Simple content extensions +//! +//! # Architecture +//! +//! 1. **Data model** ([`XsdSchema`], [`XsdElement`], [`XsdType`], etc.) -- an +//! algebraic representation of the schema structure. +//! 2. **Schema parser** ([`parse_xsd`]) -- reads an XSD XML document and +//! produces an `XsdSchema`. +//! 3. **Validator** ([`validate_xsd`]) -- checks an XML document tree against +//! a compiled schema. +//! +//! # Examples +//! +//! ``` +//! use xmloxide::Document; +//! use xmloxide::validation::xsd::{parse_xsd, validate_xsd}; +//! +//! let schema_xml = r#" +//! <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> +//! <xs:element name="greeting" type="xs:string"/> +//! </xs:schema> +//! "#; +//! +//! let schema = parse_xsd(schema_xml).unwrap(); +//! let doc = Document::parse_str("<greeting>Hello!</greeting>").unwrap(); +//! let result = validate_xsd(&doc, &schema); +//! assert!(result.is_valid); +//! ``` + +use std::collections::{HashMap, HashSet}; + +use crate::tree::{Document, NodeId, NodeKind}; +use crate::validation::{ValidationError, ValidationResult}; + +/// The XML Schema namespace URI. +const XSD_NAMESPACE: &str = "http://www.w3.org/2001/XMLSchema"; + +// --------------------------------------------------------------------------- +// Schema resolver +// --------------------------------------------------------------------------- + +/// A trait for resolving external schema documents by URI. +/// +/// Implementors provide schema content for `xsd:import` and `xsd:include` +/// directives. The resolver receives the `schemaLocation` URI and an optional +/// base URI for resolving relative paths. +/// +/// A blanket implementation is provided for closures matching +/// `Fn(&str, Option<&str>) -> Option<String>`. +/// +/// See XSD 1.0 section 4.2 for schema composition. +pub trait SchemaResolver { + /// Resolves a schema location to its XML content. + /// + /// `location` is the `schemaLocation` attribute value, which may be + /// an absolute URI or a relative path. `base` is the URI of the + /// including/importing schema, if known, for resolving relative paths. + /// + /// Returns `Some(xml_content)` if the schema was found, or `None` if + /// the schema cannot be resolved. + fn resolve(&self, location: &str, base: Option<&str>) -> Option<String>; +} + +impl<F> SchemaResolver for F +where + F: Fn(&str, Option<&str>) -> Option<String>, +{ + fn resolve(&self, location: &str, base: Option<&str>) -> Option<String> { + self(location, base) + } +} + +/// Options for parsing XSD schemas with multi-file schema composition. +/// +/// See XSD 1.0 section 4.2 for `xsd:include` and `xsd:import`. +pub struct XsdParseOptions<'a> { + /// Optional resolver for `xsd:include` and `xsd:import` directives. + /// + /// If `None`, include/import directives are silently ignored (matching + /// the current behavior of [`parse_xsd`]). + pub resolver: Option<&'a dyn SchemaResolver>, + + /// Optional base URI for resolving relative `schemaLocation` values. + pub base_uri: Option<String>, +} + +// --------------------------------------------------------------------------- +// Data model +// --------------------------------------------------------------------------- + +/// A parsed XML Schema definition. +/// +/// Contains all top-level declarations extracted from an `<xs:schema>` document: +/// global element declarations, named type definitions, and attribute groups. +#[derive(Debug, Clone)] +pub struct XsdSchema { + /// The target namespace of the schema, if declared. + pub target_namespace: Option<String>, + /// Global element declarations, keyed by element name. + elements: HashMap<String, XsdElement>, + /// Named type definitions (both simple and complex), keyed by type name. + types: HashMap<String, XsdType>, + /// Named attribute groups, keyed by group name. + attribute_groups: HashMap<String, Vec<XsdAttribute>>, + /// Imported schemas from other namespaces, keyed by namespace URI. + imported_namespaces: HashMap<String, ImportedSchema>, + /// Prefix-to-namespace-URI map from the root schema element. + /// + /// Used during validation to resolve `QName` type references like + /// `tns:AddressType` to the correct namespace for imported type lookup. + prefix_map: HashMap<String, String>, + /// The `elementFormDefault` attribute from the schema root. + /// + /// When `Qualified`, local element declarations must be namespace-qualified + /// in instance documents. Default is `Unqualified`. + /// + /// See XSD 1.0 section 3.3.2. + element_form_default: FormDefault, +} + +/// Whether local elements/attributes must be namespace-qualified in instances. +/// +/// See XSD 1.0 section 3.3.2. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FormDefault { + /// Local elements do not need to be namespace-qualified (default). + Unqualified, + /// Local elements must be namespace-qualified in instance documents. + Qualified, +} + +/// Declarations imported from another namespace via `xsd:import`. +/// +/// See XSD 1.0 section 4.2.3. +#[derive(Debug, Clone)] +struct ImportedSchema { + /// Global element declarations from the imported namespace. + elements: HashMap<String, XsdElement>, + /// Named type definitions from the imported namespace. + types: HashMap<String, XsdType>, + /// Named attribute groups from the imported namespace. + attribute_groups: HashMap<String, Vec<XsdAttribute>>, +} + +/// An element declaration in the schema. +/// +/// Elements can reference a named type via `type_ref`, define an inline type, +/// or default to `xs:anyType` if neither is specified. +/// +/// See XSD 1.0 section 3.3: Element Declarations. +#[derive(Debug, Clone)] +pub struct XsdElement { + /// The element name. + name: String, + /// Reference to a named type (e.g., `"xs:string"` or a user-defined name). + type_ref: Option<String>, + /// An inline anonymous type definition. + inline_type: Option<XsdType>, + /// Reference to a global element declaration (`ref` attribute `QName`). + /// + /// When present, the element's type is resolved from the referenced + /// global element declaration rather than from `type_ref` or `inline_type`. + element_ref: Option<String>, + /// Minimum number of occurrences (default 1 for local elements). + min_occurs: u32, + /// Maximum number of occurrences (default 1 for local elements). + max_occurs: MaxOccurs, +} + +/// Maximum occurrence constraint for particles. +/// +/// Can be a concrete bound or unbounded (no upper limit). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MaxOccurs { + /// A concrete upper bound. + Bounded(u32), + /// No upper limit (corresponds to `maxOccurs="unbounded"`). + Unbounded, +} + +impl std::fmt::Display for MaxOccurs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Bounded(n) => write!(f, "{n}"), + Self::Unbounded => write!(f, "unbounded"), + } + } +} + +/// A type definition, either simple or complex. +/// +/// See XSD 1.0 section 3.4 (Complex Type) and 3.14 (Simple Type). +#[derive(Debug, Clone)] +pub enum XsdType { + /// A simple type for text-only content and attribute values. + Simple(SimpleType), + /// A complex type that can contain elements, attributes, and mixed content. + Complex(ComplexType), +} + +/// A simple type definition for text content and attribute values. +/// +/// Simple types constrain the textual content of elements and attributes. +/// They are defined by restriction, list, or union derivation. +/// +/// See XSD 1.0 section 3.14: Simple Type Definitions. +#[derive(Debug, Clone)] +pub struct SimpleType { + /// The type name, if this is a named (non-anonymous) type. + name: Option<String>, + /// The variety of the simple type. + variety: SimpleTypeVariety, +} + +/// The variety (derivation method) of a simple type. +#[derive(Debug, Clone)] +pub enum SimpleTypeVariety { + /// A restriction on a base type, optionally with constraining facets. + Restriction { + /// The base type name being restricted. + base: String, + /// Facets that further constrain the value space. + facets: Vec<Facet>, + }, + /// A list type whose items are whitespace-separated values of the item type. + List { + /// The name of the type for list items. + item_type: String, + }, + /// A union of multiple simple types. + Union { + /// The member type names. + member_types: Vec<String>, + }, + /// A reference to a built-in type by name. + Builtin(String), +} + +/// A constraining facet on a simple type restriction. +/// +/// See XSD 1.0 section 4.3: Constraining Facets. +#[derive(Debug, Clone)] +pub enum Facet { + /// Minimum number of characters / list items. + MinLength(usize), + /// Maximum number of characters / list items. + MaxLength(usize), + /// Exact number of characters / list items. + Length(usize), + /// A regular expression pattern the value must match. + Pattern(String), + /// An enumeration of allowed values. + Enumeration(Vec<String>), + /// Inclusive lower bound for ordered values. + MinInclusive(String), + /// Inclusive upper bound for ordered values. + MaxInclusive(String), + /// Exclusive lower bound for ordered values. + MinExclusive(String), + /// Exclusive upper bound for ordered values. + MaxExclusive(String), + /// Whitespace normalization rule. + WhiteSpace(WhiteSpaceValue), + /// Maximum total number of digits for decimal types. + TotalDigits(usize), + /// Maximum number of fractional digits for decimal types. + FractionDigits(usize), +} + +/// Whitespace normalization mode for simple type values. +/// +/// See XSD 1.0 section 4.3.6. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WhiteSpaceValue { + /// Preserve all whitespace characters as-is. + Preserve, + /// Replace all occurrences of tab, line feed, and carriage return with space. + Replace, + /// After replacing, collapse contiguous sequences of spaces into one + /// and strip leading/trailing spaces. + Collapse, +} + +/// A complex type definition for structured content. +/// +/// Complex types describe elements that may contain child elements, +/// attributes, and optionally mixed text content. +/// +/// See XSD 1.0 section 3.4: Complex Type Definitions. +#[derive(Debug, Clone)] +pub struct ComplexType { + /// The type name, if this is a named (non-anonymous) type. + name: Option<String>, + /// The content model of the complex type. + content: ComplexContent, + /// Attribute declarations on elements of this type. + attributes: Vec<XsdAttribute>, + /// Whether the type allows mixed content (text interspersed with elements). + mixed: bool, +} + +/// The content model of a complex type. +#[derive(Debug, Clone)] +pub enum ComplexContent { + /// No child elements or text content allowed. + Empty, + /// An ordered sequence of particles, all of which must appear in order. + Sequence(Vec<XsdParticle>), + /// A choice among particles, exactly one of which must appear. + Choice(Vec<XsdParticle>), + /// An unordered collection where each particle may appear at most once. + All(Vec<XsdParticle>), + /// Simple content (text only) derived from a base type. + SimpleContent { + /// The base type name. + base: String, + }, +} + +/// A particle in a content model -- either an element or a nested group. +#[derive(Debug, Clone)] +pub enum XsdParticle { + /// An element declaration within the content model. + Element(XsdElement), + /// A nested compositor group (sequence, choice, or all). + Group(ComplexContent), +} + +/// An attribute declaration. +/// +/// See XSD 1.0 section 3.2: Attribute Declarations. +#[derive(Debug, Clone)] +pub struct XsdAttribute { + /// The attribute name. + name: String, + /// Reference to the attribute's type (e.g., `"xs:string"`). + type_ref: String, + /// Whether the attribute is required (`use="required"`). + required: bool, + /// Fixed value that the attribute must have if present. + fixed: Option<String>, +} + +// --------------------------------------------------------------------------- +// Schema parser +// --------------------------------------------------------------------------- + +/// Parses an XSD schema from its XML text representation. +/// +/// The input should be a well-formed XML document with an `<xs:schema>` root +/// element using the XML Schema namespace +/// (`http://www.w3.org/2001/XMLSchema`). +/// +/// This is a convenience wrapper around [`parse_xsd_with_options`] that does +/// not resolve `xsd:include` or `xsd:import` directives (they are silently +/// ignored). +/// +/// # Errors +/// +/// Returns a [`ValidationError`] if the input cannot be parsed as XML or +/// does not contain a valid XSD schema structure. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::validation::xsd::parse_xsd; +/// +/// let schema = parse_xsd(r#" +/// <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> +/// <xs:element name="root" type="xs:string"/> +/// </xs:schema> +/// "#).unwrap(); +/// ``` +pub fn parse_xsd(schema_xml: &str) -> Result<XsdSchema, ValidationError> { + parse_xsd_with_options( + schema_xml, + &XsdParseOptions { + resolver: None, + base_uri: None, + }, + ) +} + +/// Parses an XSD schema with support for `xsd:include` and `xsd:import`. +/// +/// When a [`SchemaResolver`] is provided in the options, `xsd:include` and +/// `xsd:import` elements trigger loading and merging of referenced schemas. +/// +/// See XSD 1.0 section 4.2 for schema composition rules. +/// +/// # Errors +/// +/// Returns a [`ValidationError`] if the input cannot be parsed as XML, does +/// not contain a valid XSD schema structure, or if an included/imported +/// schema cannot be resolved or has a namespace mismatch. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::validation::xsd::{parse_xsd_with_options, SchemaResolver, XsdParseOptions}; +/// +/// // A simple resolver that returns schema content by location +/// let resolver = |location: &str, _base: Option<&str>| -> Option<String> { +/// match location { +/// "types.xsd" => Some(r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> +/// <xs:complexType name="NameType"><xs:sequence> +/// <xs:element name="first" type="xs:string"/> +/// </xs:sequence></xs:complexType> +/// </xs:schema>"#.to_string()), +/// _ => None, +/// } +/// }; +/// +/// let opts = XsdParseOptions { +/// resolver: Some(&resolver), +/// base_uri: None, +/// }; +/// +/// let schema = parse_xsd_with_options(r#" +/// <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> +/// <xs:include schemaLocation="types.xsd"/> +/// <xs:element name="name" type="NameType"/> +/// </xs:schema> +/// "#, &opts).unwrap(); +/// ``` +pub fn parse_xsd_with_options( + schema_xml: &str, + options: &XsdParseOptions<'_>, +) -> Result<XsdSchema, ValidationError> { + // Parse the root schema document first to extract the prefix map + let root_doc = Document::parse_str(schema_xml).map_err(|e| ValidationError { + message: format!("failed to parse XSD schema XML: {e}"), + line: None, + column: None, + })?; + let root_elem = root_doc.root_element().ok_or_else(|| ValidationError { + message: "XSD schema has no root element".to_string(), + line: None, + column: None, + })?; + let prefix_map = build_prefix_map(&root_doc, root_elem); + let element_form_default = match root_doc.attribute(root_elem, "elementFormDefault") { + Some("qualified") => FormDefault::Qualified, + _ => FormDefault::Unqualified, + }; + + let mut schema = XsdSchema { + target_namespace: None, + elements: HashMap::new(), + types: HashMap::new(), + attribute_groups: HashMap::new(), + imported_namespaces: HashMap::new(), + prefix_map, + element_form_default, + }; + + register_builtin_types(&mut schema); + + let mut loaded = HashSet::new(); + // Use a synthetic key for the top-level schema (it has no schemaLocation) + loaded.insert("<root>".to_string()); + + parse_xsd_internal(schema_xml, options, &mut loaded, &mut schema)?; + + Ok(schema) +} + +/// Internal recursive schema parser with cycle detection. +fn parse_xsd_internal( + schema_xml: &str, + options: &XsdParseOptions<'_>, + loaded: &mut HashSet<String>, + schema: &mut XsdSchema, +) -> Result<(), ValidationError> { + let doc = Document::parse_str(schema_xml).map_err(|e| ValidationError { + message: format!("failed to parse XSD schema XML: {e}"), + line: None, + column: None, + })?; + + let root = doc.root_element().ok_or_else(|| ValidationError { + message: "XSD schema has no root element".to_string(), + line: None, + column: None, + })?; + + let root_name = doc.node_name(root).unwrap_or(""); + if root_name != "schema" { + return Err(ValidationError { + message: format!("expected <xs:schema> root element, found <{root_name}>"), + line: None, + column: None, + }); + } + + let this_ns = doc.attribute(root, "targetNamespace").map(String::from); + + // Set target_namespace from the first schema we parse (the root) + if schema.target_namespace.is_none() && this_ns.is_some() { + schema.target_namespace.clone_from(&this_ns); + } + + parse_top_level_declarations(&doc, root, schema, options, loaded, this_ns.as_ref())?; + + Ok(()) +} + +/// Parses top-level declarations from the schema root element. +fn parse_top_level_declarations( + doc: &Document, + root: NodeId, + schema: &mut XsdSchema, + options: &XsdParseOptions<'_>, + loaded: &mut HashSet<String>, + this_ns: Option<&String>, +) -> Result<(), ValidationError> { + for child in doc.children(root) { + let Some(name) = doc.node_name(child) else { + continue; + }; + match name { + "element" => { + if let Some(elem) = parse_element_decl(doc, child) { + schema.elements.insert(elem.name.clone(), elem); + } + } + "complexType" => { + let ct = parse_complex_type(doc, child); + if let Some(ref type_name) = ct.name { + schema.types.insert(type_name.clone(), XsdType::Complex(ct)); + } + } + "simpleType" => { + let st = parse_simple_type(doc, child); + if let Some(ref type_name) = st.name { + schema.types.insert(type_name.clone(), XsdType::Simple(st)); + } + } + "attributeGroup" => { + if let Some(group_name) = doc.attribute(child, "name") { + let attrs = parse_attributes(doc, child); + schema + .attribute_groups + .insert(group_name.to_string(), attrs); + } + } + "include" => { + handle_include(doc, child, schema, options, loaded, this_ns)?; + } + "import" => { + handle_import(doc, child, schema, options, loaded)?; + } + _ => {} + } + } + Ok(()) +} + +/// Handles an `<xsd:include>` element by resolving and merging the included +/// schema into the current schema. +/// +/// See XSD 1.0 section 4.2.1. +fn handle_include( + doc: &Document, + node: NodeId, + schema: &mut XsdSchema, + options: &XsdParseOptions<'_>, + loaded: &mut HashSet<String>, + this_ns: Option<&String>, +) -> Result<(), ValidationError> { + let Some(location) = doc.attribute(node, "schemaLocation") else { + return Ok(()); + }; + + // Cycle detection + if loaded.contains(location) { + return Ok(()); + } + + let Some(resolver) = options.resolver else { + return Ok(()); + }; + + let content = resolver + .resolve(location, options.base_uri.as_deref()) + .ok_or_else(|| ValidationError { + message: format!("cannot resolve included schema: {location}"), + line: None, + column: None, + })?; + + // Check namespace compatibility before merging: parse just the root to + // extract its targetNamespace. + let included_doc = Document::parse_str(&content).map_err(|e| ValidationError { + message: format!("failed to parse included schema '{location}': {e}"), + line: None, + column: None, + })?; + let included_root = included_doc.root_element().ok_or_else(|| ValidationError { + message: format!("included schema '{location}' has no root element"), + line: None, + column: None, + })?; + let included_ns = included_doc + .attribute(included_root, "targetNamespace") + .map(String::from); + + // Per XSD 1.0 §4.2.1: included schema must have the same targetNamespace + // or no targetNamespace (chameleon include). + if let Some(ref inc_ns) = included_ns { + if this_ns != Some(inc_ns) { + return Err(ValidationError { + message: format!( + "included schema '{location}' has targetNamespace '{inc_ns}' \ + which does not match the including schema's namespace" + ), + line: None, + column: None, + }); + } + } + + // Mark as loaded before recursing to prevent cycles + loaded.insert(location.to_string()); + + // Parse and merge the included schema's declarations + parse_xsd_internal(&content, options, loaded, schema)?; + + Ok(()) +} + +/// Handles an `<xsd:import>` element by resolving the imported schema and +/// storing its declarations under the imported namespace. +/// +/// See XSD 1.0 section 4.2.3. +fn handle_import( + doc: &Document, + node: NodeId, + schema: &mut XsdSchema, + options: &XsdParseOptions<'_>, + loaded: &mut HashSet<String>, +) -> Result<(), ValidationError> { + let namespace = doc.attribute(node, "namespace").map(String::from); + let location = doc.attribute(node, "schemaLocation"); + + let Some(location) = location else { + // Import without schemaLocation is valid — just declares the namespace + return Ok(()); + }; + + // Cycle detection + if loaded.contains(location) { + return Ok(()); + } + + let Some(resolver) = options.resolver else { + return Ok(()); + }; + + let content = resolver + .resolve(location, options.base_uri.as_deref()) + .ok_or_else(|| ValidationError { + message: format!("cannot resolve imported schema: {location}"), + line: None, + column: None, + })?; + + // Parse the imported schema to extract its declarations + let imported_doc = Document::parse_str(&content).map_err(|e| ValidationError { + message: format!("failed to parse imported schema '{location}': {e}"), + line: None, + column: None, + })?; + let imported_root = imported_doc.root_element().ok_or_else(|| ValidationError { + message: format!("imported schema '{location}' has no root element"), + line: None, + column: None, + })?; + + let imported_root_name = imported_doc.node_name(imported_root).unwrap_or(""); + if imported_root_name != "schema" { + return Err(ValidationError { + message: format!( + "imported schema '{location}' has root <{imported_root_name}>, expected <xs:schema>" + ), + line: None, + column: None, + }); + } + + let imported_ns = imported_doc + .attribute(imported_root, "targetNamespace") + .map(String::from); + + // Verify namespace matches if both are specified + if let (Some(ref expected), Some(ref actual)) = (&namespace, &imported_ns) { + if expected != actual { + return Err(ValidationError { + message: format!( + "imported schema '{location}' has targetNamespace '{actual}' \ + but import declares namespace '{expected}'" + ), + line: None, + column: None, + }); + } + } + + let ns_key = namespace.or(imported_ns).unwrap_or_default(); + + // Mark as loaded before recursing + loaded.insert(location.to_string()); + + // Build an ImportedSchema by parsing the imported schema's declarations + let mut imported = ImportedSchema { + elements: HashMap::new(), + types: HashMap::new(), + attribute_groups: HashMap::new(), + }; + + // We need a temporary XsdSchema to parse into, then extract declarations + let imported_form_default = match imported_doc.attribute(imported_root, "elementFormDefault") { + Some("qualified") => FormDefault::Qualified, + _ => FormDefault::Unqualified, + }; + let mut temp_schema = XsdSchema { + target_namespace: Some(ns_key.clone()), + elements: HashMap::new(), + types: HashMap::new(), + attribute_groups: HashMap::new(), + imported_namespaces: HashMap::new(), + prefix_map: build_prefix_map(&imported_doc, imported_root), + element_form_default: imported_form_default, + }; + register_builtin_types(&mut temp_schema); + parse_top_level_declarations( + &imported_doc, + imported_root, + &mut temp_schema, + options, + loaded, + Some(&ns_key), + )?; + + // Move non-builtin declarations to the ImportedSchema + for (name, typ) in &temp_schema.types { + // Skip built-in types — they are already registered on the main schema + if matches!(typ, XsdType::Simple(st) if matches!(st.variety, SimpleTypeVariety::Builtin(_))) + { + continue; + } + imported.types.insert(name.clone(), typ.clone()); + } + imported.elements = temp_schema.elements; + imported.attribute_groups = temp_schema.attribute_groups; + + // Also merge any transitive imports + for (k, v) in temp_schema.imported_namespaces { + schema.imported_namespaces.entry(k).or_insert(v); + } + + schema.imported_namespaces.entry(ns_key).or_insert(imported); + + Ok(()) +} + +/// Registers all supported built-in XSD types in the schema. +fn register_builtin_types(schema: &mut XsdSchema) { + let builtins = [ + "string", + "normalizedString", + "token", + "integer", + "int", + "long", + "short", + "byte", + "positiveInteger", + "nonNegativeInteger", + "negativeInteger", + "nonPositiveInteger", + "unsignedInt", + "unsignedLong", + "unsignedShort", + "unsignedByte", + "decimal", + "float", + "double", + "boolean", + "date", + "dateTime", + "time", + "anyURI", + "ID", + "IDREF", + "NMTOKEN", + "anyType", + "anySimpleType", + ]; + for name in builtins { + schema.types.insert( + name.to_string(), + XsdType::Simple(SimpleType { + name: Some(name.to_string()), + variety: SimpleTypeVariety::Builtin(name.to_string()), + }), + ); + } +} + +/// Parses an `<xs:element>` declaration. +/// +/// Handles both named declarations (`name="foo" type="xs:string"`) and +/// element references (`ref="cbc:ID"`). For references, the `ref` `QName` +/// is stored in `element_ref` and the local name is used as the element +/// name for matching. +fn parse_element_decl(doc: &Document, node: NodeId) -> Option<XsdElement> { + let min_occurs = doc + .attribute(node, "minOccurs") + .and_then(|v| v.parse::<u32>().ok()) + .unwrap_or(1); + let max_occurs = doc + .attribute(node, "maxOccurs") + .map_or(MaxOccurs::Bounded(1), |v| { + if v == "unbounded" { + MaxOccurs::Unbounded + } else { + MaxOccurs::Bounded(v.parse::<u32>().unwrap_or(1)) + } + }); + + // Handle ref="prefix:name" — reference to a global element declaration + if let Some(ref_qname) = doc.attribute(node, "ref") { + let local_name = if let Some((_prefix, local)) = ref_qname.split_once(':') { + local.to_string() + } else { + ref_qname.to_string() + }; + return Some(XsdElement { + name: local_name, + type_ref: None, + inline_type: None, + element_ref: Some(ref_qname.to_string()), + min_occurs, + max_occurs, + }); + } + + let name = doc.attribute(node, "name")?.to_string(); + let type_ref = doc.attribute(node, "type").map(strip_xs_prefix); + let inline_type = find_inline_type(doc, node); + Some(XsdElement { + name, + type_ref, + inline_type, + element_ref: None, + min_occurs, + max_occurs, + }) +} + +/// Looks for an inline `<xs:complexType>` or `<xs:simpleType>` child. +fn find_inline_type(doc: &Document, node: NodeId) -> Option<XsdType> { + for child in doc.children(node) { + let Some(child_name) = doc.node_name(child) else { + continue; + }; + match child_name { + "complexType" => return Some(XsdType::Complex(parse_complex_type(doc, child))), + "simpleType" => { + return Some(XsdType::Simple(parse_simple_type(doc, child))); + } + _ => {} + } + } + None +} + +/// Parses an `<xs:complexType>` element. +fn parse_complex_type(doc: &Document, node: NodeId) -> ComplexType { + let name = doc.attribute(node, "name").map(String::from); + let mixed = doc.attribute(node, "mixed") == Some("true"); + let mut content = ComplexContent::Empty; + let mut attributes = Vec::new(); + + for child in doc.children(node) { + let Some(child_name) = doc.node_name(child) else { + continue; + }; + match child_name { + "sequence" => content = parse_compositor(doc, child, CompositorKind::Sequence), + "choice" => content = parse_compositor(doc, child, CompositorKind::Choice), + "all" => content = parse_compositor(doc, child, CompositorKind::All), + "attribute" => { + if let Some(attr) = parse_attribute_decl(doc, child) { + attributes.push(attr); + } + } + "simpleContent" => { + content = parse_simple_content(doc, child); + collect_simple_content_attributes(doc, child, &mut attributes); + } + _ => {} + } + } + ComplexType { + name, + content, + attributes, + mixed, + } +} + +/// Collects attribute declarations from `<xs:simpleContent>` extension children. +fn collect_simple_content_attributes( + doc: &Document, + sc_node: NodeId, + attributes: &mut Vec<XsdAttribute>, +) { + for sc_child in doc.children(sc_node) { + if doc.node_name(sc_child) == Some("extension") { + for ext_child in doc.children(sc_child) { + if doc.node_name(ext_child) == Some("attribute") { + if let Some(attr) = parse_attribute_decl(doc, ext_child) { + attributes.push(attr); + } + } + } + } + } +} + +/// Compositor kind for parsing content model groups. +#[derive(Clone, Copy)] +enum CompositorKind { + Sequence, + Choice, + All, +} + +/// Parses a compositor (`<xs:sequence>`, `<xs:choice>`, or `<xs:all>`). +fn parse_compositor(doc: &Document, node: NodeId, kind: CompositorKind) -> ComplexContent { + let mut particles = Vec::new(); + for child in doc.children(node) { + let Some(child_name) = doc.node_name(child) else { + continue; + }; + match child_name { + "element" => { + if let Some(elem) = parse_element_decl(doc, child) { + particles.push(XsdParticle::Element(elem)); + } + } + "sequence" => { + particles.push(XsdParticle::Group(parse_compositor( + doc, + child, + CompositorKind::Sequence, + ))); + } + "choice" => { + particles.push(XsdParticle::Group(parse_compositor( + doc, + child, + CompositorKind::Choice, + ))); + } + "all" => { + particles.push(XsdParticle::Group(parse_compositor( + doc, + child, + CompositorKind::All, + ))); + } + _ => {} + } + } + match kind { + CompositorKind::Sequence => ComplexContent::Sequence(particles), + CompositorKind::Choice => ComplexContent::Choice(particles), + CompositorKind::All => ComplexContent::All(particles), + } +} + +/// Parses `<xs:simpleContent>` within a complex type. +fn parse_simple_content(doc: &Document, node: NodeId) -> ComplexContent { + for child in doc.children(node) { + if matches!(doc.node_name(child), Some("extension" | "restriction")) { + if let Some(base) = doc.attribute(child, "base") { + return ComplexContent::SimpleContent { + base: strip_xs_prefix(base), + }; + } + } + } + ComplexContent::Empty +} + +/// Parses an `<xs:simpleType>` element. +fn parse_simple_type(doc: &Document, node: NodeId) -> SimpleType { + let name = doc.attribute(node, "name").map(String::from); + for child in doc.children(node) { + let Some(child_name) = doc.node_name(child) else { + continue; + }; + match child_name { + "restriction" => { + let base = doc + .attribute(child, "base") + .map_or_else(|| "string".to_string(), strip_xs_prefix); + let facets = parse_facets(doc, child); + return SimpleType { + name, + variety: SimpleTypeVariety::Restriction { base, facets }, + }; + } + "list" => { + let item_type = doc + .attribute(child, "itemType") + .map_or_else(|| "string".to_string(), strip_xs_prefix); + return SimpleType { + name, + variety: SimpleTypeVariety::List { item_type }, + }; + } + "union" => { + let member_types = doc + .attribute(child, "memberTypes") + .map_or_else(Vec::new, |mt| { + mt.split_whitespace().map(strip_xs_prefix).collect() + }); + return SimpleType { + name, + variety: SimpleTypeVariety::Union { member_types }, + }; + } + _ => {} + } + } + SimpleType { + name, + variety: SimpleTypeVariety::Builtin("string".to_string()), + } +} + +/// Parses facet children from an `<xs:restriction>` element. +fn parse_facets(doc: &Document, restriction_node: NodeId) -> Vec<Facet> { + let mut facets = Vec::new(); + let mut enumerations = Vec::new(); + for child in doc.children(restriction_node) { + let Some(child_name) = doc.node_name(child) else { + continue; + }; + let Some(value) = doc.attribute(child, "value") else { + continue; + }; + match child_name { + "minLength" => { + if let Ok(n) = value.parse::<usize>() { + facets.push(Facet::MinLength(n)); + } + } + "maxLength" => { + if let Ok(n) = value.parse::<usize>() { + facets.push(Facet::MaxLength(n)); + } + } + "length" => { + if let Ok(n) = value.parse::<usize>() { + facets.push(Facet::Length(n)); + } + } + "pattern" => facets.push(Facet::Pattern(value.to_string())), + "enumeration" => enumerations.push(value.to_string()), + "minInclusive" => facets.push(Facet::MinInclusive(value.to_string())), + "maxInclusive" => facets.push(Facet::MaxInclusive(value.to_string())), + "minExclusive" => facets.push(Facet::MinExclusive(value.to_string())), + "maxExclusive" => facets.push(Facet::MaxExclusive(value.to_string())), + "whiteSpace" => { + let ws = match value { + "replace" => WhiteSpaceValue::Replace, + "collapse" => WhiteSpaceValue::Collapse, + _ => WhiteSpaceValue::Preserve, + }; + facets.push(Facet::WhiteSpace(ws)); + } + "totalDigits" => { + if let Ok(n) = value.parse::<usize>() { + facets.push(Facet::TotalDigits(n)); + } + } + "fractionDigits" => { + if let Ok(n) = value.parse::<usize>() { + facets.push(Facet::FractionDigits(n)); + } + } + _ => {} + } + } + if !enumerations.is_empty() { + facets.push(Facet::Enumeration(enumerations)); + } + facets +} + +/// Parses an `<xs:attribute>` declaration. +fn parse_attribute_decl(doc: &Document, node: NodeId) -> Option<XsdAttribute> { + let name = doc.attribute(node, "name")?.to_string(); + let type_ref = doc + .attribute(node, "type") + .map_or_else(|| "string".to_string(), strip_xs_prefix); + let required = doc.attribute(node, "use") == Some("required"); + let fixed = doc.attribute(node, "fixed").map(String::from); + Some(XsdAttribute { + name, + type_ref, + required, + fixed, + }) +} + +/// Parses all `<xs:attribute>` children of a given node. +fn parse_attributes(doc: &Document, node: NodeId) -> Vec<XsdAttribute> { + doc.children(node) + .filter(|&c| doc.node_name(c) == Some("attribute")) + .filter_map(|c| parse_attribute_decl(doc, c)) + .collect() +} + +/// Builds a prefix-to-namespace-URI map from `xmlns:*` attributes on a node. +/// +/// Scans the attributes of the given node for namespace declarations +/// (`xmlns:prefix="uri"`) and returns a map from prefix to URI. +fn build_prefix_map(doc: &Document, node: NodeId) -> HashMap<String, String> { + let mut map = HashMap::new(); + for attr in doc.attributes(node) { + if attr.prefix.as_deref() == Some("xmlns") { + map.insert(attr.name.clone(), attr.value.clone()); + } + } + map +} + +/// Resolves a `QName` type reference into a namespace URI and local name. +/// +/// Given a type reference like `"xs:string"` or `"tns:AddressType"`, splits +/// on `:` and looks up the prefix in the provided prefix map to get the +/// namespace URI. +/// +/// Returns `(None, local_name)` for unprefixed names and +/// `(Some(namespace_uri), local_name)` for prefixed names. +fn resolve_type_qname( + qname: &str, + prefix_map: &HashMap<String, String>, +) -> (Option<String>, String) { + if let Some((prefix, local)) = qname.split_once(':') { + let ns = prefix_map.get(prefix).cloned(); + (ns, local.to_string()) + } else { + (None, qname.to_string()) + } +} + +/// Strips an `xs:` or `xsd:` prefix from a type reference string. +fn strip_xs_prefix(name: &str) -> String { + if let Some(local) = name.strip_prefix("xs:") { + local.to_string() + } else if let Some(local) = name.strip_prefix("xsd:") { + local.to_string() + } else { + name.to_string() + } +} + +// --------------------------------------------------------------------------- +// Validator +// --------------------------------------------------------------------------- + +/// Validates an XML document against an XSD schema. +/// +/// Walks the document tree starting from the root element, matching elements +/// against their declarations in the schema, checking content models, +/// attribute constraints, and simple type facets. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::Document; +/// use xmloxide::validation::xsd::{parse_xsd, validate_xsd}; +/// +/// let schema = parse_xsd(r#" +/// <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> +/// <xs:element name="note" type="xs:string"/> +/// </xs:schema> +/// "#).unwrap(); +/// +/// let doc = Document::parse_str("<note>Hello</note>").unwrap(); +/// let result = validate_xsd(&doc, &schema); +/// assert!(result.is_valid); +/// ``` +pub fn validate_xsd(doc: &Document, schema: &XsdSchema) -> ValidationResult { + let mut errors = Vec::new(); + let Some(root) = doc.root_element() else { + errors.push(ValidationError { + message: "document has no root element".to_string(), + line: None, + column: None, + }); + return ValidationResult { + is_valid: false, + errors, + warnings: vec![], + }; + }; + let root_name = doc.node_name(root).unwrap_or(""); + if let Some(decl) = schema.elements.get(root_name) { + validate_element(doc, root, decl, schema, &mut errors); + } else { + errors.push(ValidationError { + message: format!( + "element <{root_name}> not declared as a global element in the schema" + ), + line: None, + column: None, + }); + } + ValidationResult { + is_valid: errors.is_empty(), + errors, + warnings: vec![], + } +} + +/// Validates a single element against its declaration. +fn validate_element( + doc: &Document, + node: NodeId, + decl: &XsdElement, + schema: &XsdSchema, + errors: &mut Vec<ValidationError>, +) { + match resolve_element_type(decl, schema) { + Some(XsdType::Complex(ct)) => validate_complex_element(doc, node, ct, schema, errors), + Some(XsdType::Simple(st)) => validate_simple_element(doc, node, st, schema, errors), + None => {} // anyType + } +} + +/// Resolves the type for an element declaration, checking both local types +/// and imported namespaces for QName-prefixed type references. +/// +/// For element references (`ref="cbc:ID"`), resolves the referenced global +/// element declaration and returns its type. +fn resolve_element_type<'a>(decl: &'a XsdElement, schema: &'a XsdSchema) -> Option<&'a XsdType> { + // Handle element ref — look up the referenced global element's type + if let Some(ref ref_qname) = decl.element_ref { + if let Some(ref_decl) = resolve_element_ref(ref_qname, schema) { + return resolve_element_type(ref_decl, schema); + } + return None; + } + if let Some(ref inline) = decl.inline_type { + return Some(inline); + } + if let Some(ref type_name) = decl.type_ref { + return resolve_type_name(type_name, schema); + } + None +} + +/// Resolves a type by name, checking local types first, then imported namespaces. +fn resolve_type_name<'a>(type_name: &str, schema: &'a XsdSchema) -> Option<&'a XsdType> { + // Try local types first (handles unprefixed names and xs:-stripped names) + if let Some(t) = schema.types.get(type_name) { + return Some(t); + } + // Try namespace-aware resolution for prefixed type references + let (ns, local) = resolve_type_qname(type_name, &schema.prefix_map); + if let Some(ref ns_uri) = ns { + if ns_uri == XSD_NAMESPACE { + // Built-in XSD type — look up by local name + return schema.types.get(&local); + } + // Check imported namespaces + if let Some(imported) = schema.imported_namespaces.get(ns_uri) { + return imported.types.get(&local); + } + } + None +} + +/// Resolves an element reference `QName` to its global element declaration. +/// +/// Checks local elements first, then imported namespaces for prefixed refs. +fn resolve_element_ref<'a>(ref_qname: &str, schema: &'a XsdSchema) -> Option<&'a XsdElement> { + // Unprefixed ref — look up in local elements + if !ref_qname.contains(':') { + return schema.elements.get(ref_qname); + } + // Prefixed ref — resolve namespace and look up in imported elements + let (ns, local) = resolve_type_qname(ref_qname, &schema.prefix_map); + if let Some(ref ns_uri) = ns { + if let Some(imported) = schema.imported_namespaces.get(ns_uri) { + return imported.elements.get(&local); + } + } + None +} + +/// Validates an element with a complex type. +fn validate_complex_element( + doc: &Document, + node: NodeId, + ct: &ComplexType, + schema: &XsdSchema, + errors: &mut Vec<ValidationError>, +) { + let elem_name = doc.node_name(node).unwrap_or("<unknown>"); + validate_attributes(doc, node, &ct.attributes, schema, errors); + match &ct.content { + ComplexContent::Empty => validate_empty_content(doc, node, elem_name, ct.mixed, errors), + ComplexContent::Sequence(p) => { + let ce = collect_child_elements(doc, node); + validate_sequence(doc, &ce, p, elem_name, schema, errors); + } + ComplexContent::Choice(p) => { + let ce = collect_child_elements(doc, node); + validate_choice(doc, &ce, p, elem_name, schema, errors); + } + ComplexContent::All(p) => { + let ce = collect_child_elements(doc, node); + validate_all(doc, &ce, p, elem_name, schema, errors); + } + ComplexContent::SimpleContent { base } => { + let text = doc.text_content(node); + if let Some(XsdType::Simple(st)) = schema.types.get(base.as_str()) { + validate_simple_value(&text, st, elem_name, schema, errors); + } + } + } +} + +/// Validates empty content model constraints. +fn validate_empty_content( + doc: &Document, + node: NodeId, + elem_name: &str, + mixed: bool, + errors: &mut Vec<ValidationError>, +) { + let has_children = doc + .children(node) + .any(|c| matches!(doc.node(c).kind, NodeKind::Element { .. })); + if has_children { + errors.push(ValidationError { + message: format!( + "element <{elem_name}> has empty content model but contains child elements" + ), + line: None, + column: None, + }); + } + if !mixed && !doc.text_content(node).trim().is_empty() { + errors.push(ValidationError { + message: format!( + "element <{elem_name}> has empty content model but contains text content" + ), + line: None, + column: None, + }); + } +} + +/// Collects child element `NodeId`s. +fn collect_child_elements(doc: &Document, node: NodeId) -> Vec<NodeId> { + doc.children(node) + .filter(|&c| matches!(doc.node(c).kind, NodeKind::Element { .. })) + .collect() +} + +/// Validates a sequence content model. +fn validate_sequence( + doc: &Document, + children: &[NodeId], + particles: &[XsdParticle], + parent_name: &str, + schema: &XsdSchema, + errors: &mut Vec<ValidationError>, +) { + let mut idx = 0; + for particle in particles { + match particle { + XsdParticle::Element(decl) => { + idx += validate_sequence_element( + doc, + &children[idx..], + decl, + parent_name, + schema, + errors, + ); + } + XsdParticle::Group(content) => { + idx += validate_group_content( + doc, + &children[idx..], + content, + parent_name, + schema, + errors, + ); + } + } + } + if idx < children.len() { + let unexpected = doc.node_name(children[idx]).unwrap_or("<unknown>"); + errors.push(ValidationError { + message: format!("unexpected element <{unexpected}> in <{parent_name}>; not expected by the content model"), + line: None, column: None, + }); + } +} + +/// Validates a single element particle in a sequence, returning number consumed. +/// Checks if an instance element matches a schema element declaration, +/// accounting for `elementFormDefault` and element-level `form` attributes. +/// +/// When qualified form is in effect, the element must have the schema's +/// target namespace. When unqualified, the element is matched by local +/// name only (no namespace required). +fn element_matches_decl( + doc: &Document, + node: NodeId, + decl: &XsdElement, + schema: &XsdSchema, +) -> bool { + let child_name = doc.node_name(node).unwrap_or(""); + if child_name != decl.name { + return false; + } + // Check namespace qualification + if schema.element_form_default == FormDefault::Qualified { + if let Some(ref target_ns) = schema.target_namespace { + let child_ns = doc.node_namespace(node).unwrap_or(""); + return child_ns == target_ns; + } + } + true +} + +fn validate_sequence_element( + doc: &Document, + children: &[NodeId], + decl: &XsdElement, + parent_name: &str, + schema: &XsdSchema, + errors: &mut Vec<ValidationError>, +) -> usize { + let mut count: u32 = 0; + let mut consumed = 0; + for &child in children { + if !element_matches_decl(doc, child, decl, schema) { + break; + } + if let MaxOccurs::Bounded(max) = decl.max_occurs { + if count >= max { + break; + } + } + validate_element(doc, child, decl, schema, errors); + count += 1; + consumed += 1; + } + if count < decl.min_occurs { + errors.push(ValidationError { + message: format!( + "element <{parent_name}> requires at least {} occurrence(s) of <{}>, found {count}", + decl.min_occurs, decl.name + ), + line: None, + column: None, + }); + } + consumed +} + +/// Validates a nested group content model, returning children consumed. +fn validate_group_content( + doc: &Document, + children: &[NodeId], + content: &ComplexContent, + parent_name: &str, + schema: &XsdSchema, + errors: &mut Vec<ValidationError>, +) -> usize { + match content { + ComplexContent::Sequence(particles) => { + let before = errors.len(); + validate_sequence(doc, children, particles, parent_name, schema, errors); + if errors.len() == before { + children.len() + } else { + 0 + } + } + ComplexContent::Choice(particles) => { + validate_choice(doc, children, particles, parent_name, schema, errors); + usize::from(!children.is_empty()) + } + _ => 0, + } +} + +/// Validates a choice content model. +fn validate_choice( + doc: &Document, + children: &[NodeId], + particles: &[XsdParticle], + parent_name: &str, + schema: &XsdSchema, + errors: &mut Vec<ValidationError>, +) { + if children.is_empty() { + let any_optional = particles + .iter() + .any(|p| matches!(p, XsdParticle::Element(d) if d.min_occurs == 0)); + if !any_optional { + errors.push(ValidationError { + message: format!("element <{parent_name}> requires one of the choice alternatives but has no child elements"), + line: None, column: None, + }); + } + return; + } + let first = children[0]; + let first_name = doc.node_name(first).unwrap_or(""); + let matched = particles.iter().any(|p| { + if let XsdParticle::Element(decl) = p { + if element_matches_decl(doc, first, decl, schema) { + validate_element(doc, first, decl, schema, errors); + return true; + } + } + false + }); + if !matched { + let choices: Vec<&str> = particles + .iter() + .filter_map(|p| { + if let XsdParticle::Element(d) = p { + Some(d.name.as_str()) + } else { + None + } + }) + .collect(); + errors.push(ValidationError { + message: format!("element <{first_name}> in <{parent_name}> does not match any choice alternative; expected one of: {}", choices.join(", ")), + line: None, column: None, + }); + } +} + +/// Validates an `all` content model. +fn validate_all( + doc: &Document, + children: &[NodeId], + particles: &[XsdParticle], + parent_name: &str, + schema: &XsdSchema, + errors: &mut Vec<ValidationError>, +) { + let mut seen: HashMap<&str, u32> = HashMap::new(); + for &child in children { + let child_name = doc.node_name(child).unwrap_or(""); + let matching = particles.iter().find( + |p| matches!(p, XsdParticle::Element(d) if element_matches_decl(doc, child, d, schema)), + ); + if let Some(XsdParticle::Element(decl)) = matching { + let count = seen.entry(child_name).or_insert(0); + *count += 1; + if let MaxOccurs::Bounded(max) = decl.max_occurs { + if *count > max { + errors.push(ValidationError { + message: format!("element <{child_name}> in <{parent_name}> appears more than {max} time(s) in all group"), + line: None, column: None, + }); + } + } + validate_element(doc, child, decl, schema, errors); + } else { + errors.push(ValidationError { + message: format!("unexpected element <{child_name}> in <{parent_name}>; not declared in the all group"), + line: None, column: None, + }); + } + } + for particle in particles { + if let XsdParticle::Element(decl) = particle { + let count = seen.get(decl.name.as_str()).copied().unwrap_or(0); + if count < decl.min_occurs { + errors.push(ValidationError { + message: format!("element <{parent_name}> requires at least {} occurrence(s) of <{}> in the all group, found {count}", decl.min_occurs, decl.name), + line: None, column: None, + }); + } + } + } +} + +/// Validates an element with a simple type. +fn validate_simple_element( + doc: &Document, + node: NodeId, + st: &SimpleType, + schema: &XsdSchema, + errors: &mut Vec<ValidationError>, +) { + let elem_name = doc.node_name(node).unwrap_or("<unknown>"); + if doc + .children(node) + .any(|c| matches!(doc.node(c).kind, NodeKind::Element { .. })) + { + errors.push(ValidationError { + message: format!("element <{elem_name}> has simple type but contains child elements"), + line: None, + column: None, + }); + return; + } + validate_simple_value(&doc.text_content(node), st, elem_name, schema, errors); +} + +/// Validates a string value against a simple type definition. +fn validate_simple_value( + value: &str, + st: &SimpleType, + context: &str, + schema: &XsdSchema, + errors: &mut Vec<ValidationError>, +) { + match &st.variety { + SimpleTypeVariety::Builtin(name) => validate_builtin_value(value, name, context, errors), + SimpleTypeVariety::Restriction { base, facets } => { + if let Some(XsdType::Simple(bt)) = schema.types.get(base.as_str()) { + validate_simple_value(value, bt, context, schema, errors); + } else { + validate_builtin_value(value, base, context, errors); + } + validate_facets(value, facets, context, errors); + } + SimpleTypeVariety::List { item_type } => { + for item in value.split_whitespace() { + if let Some(XsdType::Simple(ist)) = schema.types.get(item_type.as_str()) { + validate_simple_value(item, ist, context, schema, errors); + } else { + validate_builtin_value(item, item_type, context, errors); + } + } + } + SimpleTypeVariety::Union { member_types } => { + validate_union_value(value, member_types, context, schema, errors); + } + } +} + +/// Validates a value against a union type. +fn validate_union_value( + value: &str, + member_types: &[String], + context: &str, + schema: &XsdSchema, + errors: &mut Vec<ValidationError>, +) { + let mut any_valid = false; + for mt in member_types { + let mut trial = Vec::new(); + if let Some(XsdType::Simple(mst)) = schema.types.get(mt.as_str()) { + validate_simple_value(value, mst, context, schema, &mut trial); + } else { + validate_builtin_value(value, mt, context, &mut trial); + } + if trial.is_empty() { + any_valid = true; + break; + } + } + if !any_valid && !member_types.is_empty() { + errors.push(ValidationError { + message: format!( + "value \"{value}\" in <{context}> does not match any member type of the union" + ), + line: None, + column: None, + }); + } +} + +/// Validates a value against a built-in XSD type. +#[allow(clippy::too_many_lines)] +fn validate_builtin_value( + value: &str, + type_name: &str, + context: &str, + errors: &mut Vec<ValidationError>, +) { + match type_name { + "integer" | "long" | "int" | "short" | "byte" => { + validate_signed_integer(value, type_name, context, errors); + } + "positiveInteger" => { + validate_constrained_integer(value, context, "positiveInteger", |n| n > 0, errors); + } + "nonNegativeInteger" => { + validate_constrained_integer(value, context, "nonNegativeInteger", |n| n >= 0, errors); + } + "negativeInteger" => { + validate_constrained_integer(value, context, "negativeInteger", |n| n < 0, errors); + } + "nonPositiveInteger" => { + validate_constrained_integer(value, context, "nonPositiveInteger", |n| n <= 0, errors); + } + "unsignedInt" | "unsignedLong" | "unsignedShort" | "unsignedByte" => { + validate_unsigned_integer(value, type_name, context, errors); + } + "decimal" if parse_decimal(value).is_none() => { + errors.push(ValidationError { + message: format!("value \"{value}\" in <{context}> is not a valid decimal"), + line: None, + column: None, + }); + } + "float" | "double" + if !matches!(value, "INF" | "-INF" | "NaN") && value.parse::<f64>().is_err() => + { + errors.push(ValidationError { + message: format!("value \"{value}\" in <{context}> is not a valid {type_name}"), + line: None, + column: None, + }); + } + "boolean" if !matches!(value, "true" | "false" | "1" | "0") => { + errors.push(ValidationError { + message: format!( + "value \"{value}\" in <{context}> is not a valid boolean (expected true, false, 1, or 0)" + ), + line: None, + column: None, + }); + } + "date" if !is_valid_date_pattern(value) => { + errors.push(ValidationError { + message: format!( + "value \"{value}\" in <{context}> is not a valid date (expected YYYY-MM-DD)" + ), + line: None, + column: None, + }); + } + "dateTime" if !is_valid_datetime_pattern(value) => { + errors.push(ValidationError { + message: format!("value \"{value}\" in <{context}> is not a valid dateTime"), + line: None, + column: None, + }); + } + "time" if !is_valid_time_pattern(value) => { + errors.push(ValidationError { + message: format!( + "value \"{value}\" in <{context}> is not a valid time (expected hh:mm:ss)" + ), + line: None, + column: None, + }); + } + _ => {} + } +} + +/// Validates and range-checks a signed integer value. +fn validate_signed_integer( + value: &str, + type_name: &str, + context: &str, + errors: &mut Vec<ValidationError>, +) { + if value.parse::<i64>().is_err() { + errors.push(ValidationError { + message: format!("value \"{value}\" in <{context}> is not a valid {type_name}"), + line: None, + column: None, + }); + return; + } + check_integer_range(value, type_name, context, errors); +} + +/// Validates a constrained integer (positive, negative, etc.). +fn validate_constrained_integer( + value: &str, + context: &str, + type_name: &str, + predicate: fn(i64) -> bool, + errors: &mut Vec<ValidationError>, +) { + match value.parse::<i64>() { + Ok(n) if predicate(n) => {} + _ => { + errors.push(ValidationError { + message: format!("value \"{value}\" in <{context}> is not a valid {type_name}"), + line: None, + column: None, + }); + } + } +} + +/// Validates and range-checks an unsigned integer value. +fn validate_unsigned_integer( + value: &str, + type_name: &str, + context: &str, + errors: &mut Vec<ValidationError>, +) { + if value.parse::<u64>().is_err() { + errors.push(ValidationError { + message: format!("value \"{value}\" in <{context}> is not a valid {type_name}"), + line: None, + column: None, + }); + return; + } + check_unsigned_range(value, type_name, context, errors); +} + +/// Checks range constraints for signed integer types. +fn check_integer_range( + value: &str, + type_name: &str, + context: &str, + errors: &mut Vec<ValidationError>, +) { + let Ok(n) = value.parse::<i64>() else { return }; + let (min, max) = match type_name { + "byte" => (i64::from(i8::MIN), i64::from(i8::MAX)), + "short" => (i64::from(i16::MIN), i64::from(i16::MAX)), + "int" => (i64::from(i32::MIN), i64::from(i32::MAX)), + "long" => (i64::MIN, i64::MAX), + _ => return, + }; + if n < min || n > max { + errors.push(ValidationError { + message: format!( + "value \"{value}\" in <{context}> is out of range for {type_name} ({min}..{max})" + ), + line: None, + column: None, + }); + } +} + +/// Checks range constraints for unsigned integer types. +fn check_unsigned_range( + value: &str, + type_name: &str, + context: &str, + errors: &mut Vec<ValidationError>, +) { + let Ok(n) = value.parse::<u64>() else { return }; + let max = match type_name { + "unsignedByte" => u64::from(u8::MAX), + "unsignedShort" => u64::from(u16::MAX), + "unsignedInt" => u64::from(u32::MAX), + "unsignedLong" => u64::MAX, + _ => return, + }; + if n > max { + errors.push(ValidationError { + message: format!( + "value \"{value}\" in <{context}> is out of range for {type_name} (0..{max})" + ), + line: None, + column: None, + }); + } +} + +/// Parses a decimal value. +fn parse_decimal(value: &str) -> Option<f64> { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed.contains('e') || trimmed.contains('E') { + return None; + } + trimmed.parse::<f64>().ok() +} + +/// Basic validation for `xs:date` pattern. +fn is_valid_date_pattern(value: &str) -> bool { + let date_part = strip_timezone(value); + if let Some(without_sign) = date_part.strip_prefix('-') { + let parts: Vec<&str> = without_sign.split('-').collect(); + return parts.len() == 3 + && parts[0].len() >= 4 + && parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())); + } + let parts: Vec<&str> = date_part.split('-').collect(); + parts.len() == 3 + && parts[0].len() >= 4 + && parts[0].chars().all(|c| c.is_ascii_digit()) + && parts[1].len() == 2 + && parts[1].chars().all(|c| c.is_ascii_digit()) + && parts[2].len() == 2 + && parts[2].chars().all(|c| c.is_ascii_digit()) +} + +/// Basic validation for `xs:dateTime` pattern. +fn is_valid_datetime_pattern(value: &str) -> bool { + let dt = strip_timezone(value); + let Some((date, time)) = dt.split_once('T') else { + return false; + }; + is_valid_date_pattern(date) && is_valid_time_pattern(time) +} + +/// Basic validation for `xs:time` pattern. +fn is_valid_time_pattern(value: &str) -> bool { + let time_part = strip_timezone(value); + let parts: Vec<&str> = time_part.split(':').collect(); + if parts.len() != 3 { + return false; + } + let sec = parts[2].split('.').next().unwrap_or(""); + parts[0].len() == 2 + && parts[0].chars().all(|c| c.is_ascii_digit()) + && parts[1].len() == 2 + && parts[1].chars().all(|c| c.is_ascii_digit()) + && !sec.is_empty() + && sec.chars().all(|c| c.is_ascii_digit()) +} + +/// Strips timezone suffix. +fn strip_timezone(value: &str) -> &str { + if let Some(s) = value.strip_suffix('Z') { + return s; + } + if value.len() > 6 { + let tail = &value[value.len() - 6..]; + if (tail.starts_with('+') || tail.starts_with('-')) && tail.as_bytes().get(3) == Some(&b':') + { + return &value[..value.len() - 6]; + } + } + value +} + +/// Applies whitespace normalization to a value according to the XSD `whiteSpace` facet. +/// +/// See XSD 1.0 section 4.3.6: +/// - `Preserve`: no normalization +/// - `Replace`: replace `\t`, `\n`, `\r` with space +/// - `Collapse`: replace + collapse contiguous spaces + strip leading/trailing +fn apply_whitespace_normalization(value: &str, ws: &WhiteSpaceValue) -> String { + match ws { + WhiteSpaceValue::Preserve => value.to_string(), + WhiteSpaceValue::Replace => value + .chars() + .map(|c| { + if matches!(c, '\t' | '\n' | '\r') { + ' ' + } else { + c + } + }) + .collect(), + WhiteSpaceValue::Collapse => { + let replaced: String = value + .chars() + .map(|c| { + if matches!(c, '\t' | '\n' | '\r') { + ' ' + } else { + c + } + }) + .collect(); + replaced.split_whitespace().collect::<Vec<_>>().join(" ") + } + } +} + +/// Validates facet constraints on a string value. +fn validate_facets( + value: &str, + facets: &[Facet], + context: &str, + errors: &mut Vec<ValidationError>, +) { + // Find any WhiteSpace facet and normalize the value before checking other facets. + let normalized; + let effective_value = if let Some(ws) = facets.iter().find_map(|f| { + if let Facet::WhiteSpace(ws) = f { + Some(ws) + } else { + None + } + }) { + normalized = apply_whitespace_normalization(value, ws); + &normalized + } else { + value + }; + + for facet in facets { + validate_single_facet(effective_value, facet, context, errors); + } +} + +/// Validates a single facet constraint. +#[allow(clippy::too_many_lines)] +fn validate_single_facet( + value: &str, + facet: &Facet, + context: &str, + errors: &mut Vec<ValidationError>, +) { + match facet { + Facet::MinLength(min) => { + if value.len() < *min { + errors.push(ValidationError { + message: format!( + "value in <{context}> has length {} but minLength is {min}", + value.len() + ), + line: None, + column: None, + }); + } + } + Facet::MaxLength(max) => { + if value.len() > *max { + errors.push(ValidationError { + message: format!( + "value in <{context}> has length {} but maxLength is {max}", + value.len() + ), + line: None, + column: None, + }); + } + } + Facet::Length(len) => { + if value.len() != *len { + errors.push(ValidationError { + message: format!( + "value in <{context}> has length {} but required length is {len}", + value.len() + ), + line: None, + column: None, + }); + } + } + Facet::Pattern(pattern) => { + if !matches_xsd_pattern(value, pattern) { + errors.push(ValidationError { + message: format!( + "value \"{value}\" in <{context}> does not match pattern \"{pattern}\"" + ), + line: None, + column: None, + }); + } + } + Facet::Enumeration(allowed) => { + if !allowed.iter().any(|a| a == value) { + errors.push(ValidationError { + message: format!( + "value \"{value}\" in <{context}> is not in the enumeration: {}", + allowed.join(", ") + ), + line: None, + column: None, + }); + } + } + Facet::MinInclusive(min) => { + if let (Some(v), Some(m)) = (parse_decimal(value), parse_decimal(min)) { + if v < m { + errors.push(ValidationError { + message: format!( + "value \"{value}\" in <{context}> is less than minInclusive {min}" + ), + line: None, + column: None, + }); + } + } + } + Facet::MaxInclusive(max) => { + if let (Some(v), Some(m)) = (parse_decimal(value), parse_decimal(max)) { + if v > m { + errors.push(ValidationError { + message: format!( + "value \"{value}\" in <{context}> is greater than maxInclusive {max}" + ), + line: None, + column: None, + }); + } + } + } + Facet::MinExclusive(min) => { + if let (Some(v), Some(m)) = (parse_decimal(value), parse_decimal(min)) { + if v <= m { + errors.push(ValidationError { + message: format!("value \"{value}\" in <{context}> must be greater than minExclusive {min}"), + line: None, column: None, + }); + } + } + } + Facet::MaxExclusive(max) => { + if let (Some(v), Some(m)) = (parse_decimal(value), parse_decimal(max)) { + if v >= m { + errors.push(ValidationError { + message: format!( + "value \"{value}\" in <{context}> must be less than maxExclusive {max}" + ), + line: None, + column: None, + }); + } + } + } + Facet::TotalDigits(total) => { + let digits = count_total_digits(value); + if digits > *total { + errors.push(ValidationError { + message: format!("value \"{value}\" in <{context}> has {digits} total digits but totalDigits is {total}"), + line: None, column: None, + }); + } + } + Facet::FractionDigits(frac) => { + let digits = count_fraction_digits(value); + if digits > *frac { + errors.push(ValidationError { + message: format!("value \"{value}\" in <{context}> has {digits} fraction digits but fractionDigits is {frac}"), + line: None, column: None, + }); + } + } + Facet::WhiteSpace(_) => {} + } +} + +/// Validates element attributes against the declared attribute list. +fn validate_attributes( + doc: &Document, + node: NodeId, + declared_attrs: &[XsdAttribute], + schema: &XsdSchema, + errors: &mut Vec<ValidationError>, +) { + let elem_name = doc.node_name(node).unwrap_or("<unknown>"); + let actual_attrs = doc.attributes(node); + for decl in declared_attrs { + let actual = actual_attrs.iter().find(|a| a.name == decl.name); + if decl.required && actual.is_none() { + errors.push(ValidationError { + message: format!( + "required attribute \"{}\" missing on element <{elem_name}>", + decl.name + ), + line: None, + column: None, + }); + continue; + } + if let Some(attr) = actual { + if let Some(ref fixed) = decl.fixed { + if attr.value != *fixed { + errors.push(ValidationError { + message: format!("attribute \"{}\" on <{elem_name}> must have fixed value \"{fixed}\", found \"{}\"", decl.name, attr.value), + line: None, column: None, + }); + } + } + let attr_context = format!("{elem_name}/@{}", decl.name); + if let Some(XsdType::Simple(st)) = schema.types.get(&decl.type_ref) { + validate_simple_value(&attr.value, st, &attr_context, schema, errors); + } else { + validate_builtin_value(&attr.value, &decl.type_ref, &attr_context, errors); + } + } + } +} + +// --------------------------------------------------------------------------- +// Pattern matching +// --------------------------------------------------------------------------- + +/// Simple XSD pattern matching using basic character class support. +fn matches_xsd_pattern(value: &str, pattern: &str) -> bool { + match_pattern_chars(value.as_bytes(), pattern.as_bytes(), 0, 0) +} + +/// Recursive pattern matcher. +fn match_pattern_chars(value: &[u8], pattern: &[u8], vi: usize, pi: usize) -> bool { + if pi >= pattern.len() { + return vi >= value.len(); + } + let (cc, next_pi) = parse_pattern_element(pattern, pi); + let has_star = next_pi < pattern.len() && pattern[next_pi] == b'*'; + let has_plus = next_pi < pattern.len() && pattern[next_pi] == b'+'; + let has_question = next_pi < pattern.len() && pattern[next_pi] == b'?'; + let aq = if has_star || has_plus || has_question { + next_pi + 1 + } else { + next_pi + }; + + if has_star { + match_quantified(value, pattern, vi, aq, &cc, 0) + } else if has_plus { + match_quantified(value, pattern, vi, aq, &cc, 1) + } else if has_question { + if match_pattern_chars(value, pattern, vi, aq) { + return true; + } + vi < value.len() + && matches_char_class(value[vi], &cc) + && match_pattern_chars(value, pattern, vi + 1, aq) + } else { + vi < value.len() + && matches_char_class(value[vi], &cc) + && match_pattern_chars(value, pattern, vi + 1, next_pi) + } +} + +/// Matches `min_count` or more occurrences of a character class. +fn match_quantified( + value: &[u8], + pattern: &[u8], + vi: usize, + aq: usize, + cc: &CharClass, + min_count: usize, +) -> bool { + let mut i = vi; + let mut count = 0; + // Try zero matches first (for star) + if min_count == 0 && match_pattern_chars(value, pattern, i, aq) { + return true; + } + while i < value.len() && matches_char_class(value[i], cc) { + i += 1; + count += 1; + if count >= min_count && match_pattern_chars(value, pattern, i, aq) { + return true; + } + } + false +} + +/// A character class element in a pattern. +enum CharClass { + Literal(u8), + Dot, + Digit, + Word, + CharSet(Vec<u8>), + NegCharSet(Vec<u8>), +} + +/// Parses one pattern element. +fn parse_pattern_element(pattern: &[u8], pi: usize) -> (CharClass, usize) { + if pi >= pattern.len() { + return (CharClass::Literal(0), pi); + } + match pattern[pi] { + b'.' => (CharClass::Dot, pi + 1), + b'\\' if pi + 1 < pattern.len() => match pattern[pi + 1] { + b'd' => (CharClass::Digit, pi + 2), + b'w' => (CharClass::Word, pi + 2), + ch => (CharClass::Literal(ch), pi + 2), + }, + b'[' => parse_char_set(pattern, pi), + ch => (CharClass::Literal(ch), pi + 1), + } +} + +/// Parses a character set `[...]` or `[^...]`. +fn parse_char_set(pattern: &[u8], pi: usize) -> (CharClass, usize) { + let negated = pi + 1 < pattern.len() && pattern[pi + 1] == b'^'; + let start = if negated { pi + 2 } else { pi + 1 }; + let mut end = start; + while end < pattern.len() && pattern[end] != b']' { + end += 1; + } + let chars = expand_char_ranges(&pattern[start..end]); + let class = if negated { + CharClass::NegCharSet(chars) + } else { + CharClass::CharSet(chars) + }; + (class, if end < pattern.len() { end + 1 } else { end }) +} + +/// Expands character ranges like `a-z`. +fn expand_char_ranges(set: &[u8]) -> Vec<u8> { + let mut result = Vec::new(); + let mut i = 0; + while i < set.len() { + if i + 2 < set.len() && set[i + 1] == b'-' { + for ch in set[i]..=set[i + 2] { + result.push(ch); + } + i += 3; + } else { + result.push(set[i]); + i += 1; + } + } + result +} + +/// Tests whether a byte matches a character class. +fn matches_char_class(byte: u8, class: &CharClass) -> bool { + match class { + CharClass::Literal(ch) => byte == *ch, + CharClass::Dot => true, + CharClass::Digit => byte.is_ascii_digit(), + CharClass::Word => byte.is_ascii_alphanumeric() || byte == b'_', + CharClass::CharSet(chars) => chars.contains(&byte), + CharClass::NegCharSet(chars) => !chars.contains(&byte), + } +} + +/// Counts total significant digits. +fn count_total_digits(value: &str) -> usize { + value + .trim() + .trim_start_matches('-') + .chars() + .filter(char::is_ascii_digit) + .count() +} + +/// Counts fractional digits after the decimal point. +fn count_fraction_digits(value: &str) -> usize { + value.find('.').map_or(0, |pos| { + value[pos + 1..] + .chars() + .filter(char::is_ascii_digit) + .count() + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn make_schema(xsd: &str) -> XsdSchema { + parse_xsd(xsd).unwrap() + } + + fn validate(xsd: &str, xml: &str) -> ValidationResult { + let schema = make_schema(xsd); + let doc = Document::parse_str(xml).unwrap(); + validate_xsd(&doc, &schema) + } + + #[test] + fn test_parse_simple_schema_with_one_element() { + let schema = make_schema( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="greeting" type="xs:string"/> + </xs:schema>"#, + ); + assert!(schema.elements.contains_key("greeting")); + assert_eq!( + schema.elements["greeting"].type_ref.as_deref(), + Some("string") + ); + } + + #[test] + fn test_parse_schema_with_complex_type_and_sequence() { + let schema = make_schema( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="person"><xs:complexType><xs:sequence> + <xs:element name="name" type="xs:string"/> + <xs:element name="age" type="xs:integer"/> + </xs:sequence></xs:complexType></xs:element> + </xs:schema>"#, + ); + let elem = &schema.elements["person"]; + if let Some(XsdType::Complex(ct)) = &elem.inline_type { + if let ComplexContent::Sequence(p) = &ct.content { + assert_eq!(p.len(), 2); + } else { + panic!("expected sequence"); + } + } else { + panic!("expected complex type"); + } + } + + #[test] + fn test_parse_schema_with_simple_type_restriction_enumeration() { + let schema = make_schema( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:simpleType name="colorType"><xs:restriction base="xs:string"> + <xs:enumeration value="red"/><xs:enumeration value="green"/><xs:enumeration value="blue"/> + </xs:restriction></xs:simpleType> + </xs:schema>"#, + ); + if let Some(XsdType::Simple(st)) = schema.types.get("colorType") { + if let SimpleTypeVariety::Restriction { facets, .. } = &st.variety { + assert!(facets.iter().any(|f| matches!(f, Facet::Enumeration(_)))); + } else { + panic!("expected restriction"); + } + } else { + panic!("expected simple type"); + } + } + + #[test] + fn test_parse_schema_with_attributes() { + let schema = make_schema( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="item"><xs:complexType><xs:sequence> + <xs:element name="name" type="xs:string"/> + </xs:sequence> + <xs:attribute name="id" type="xs:integer" use="required"/> + <xs:attribute name="category" type="xs:string"/> + </xs:complexType></xs:element> + </xs:schema>"#, + ); + if let Some(XsdType::Complex(ct)) = &schema.elements["item"].inline_type { + assert_eq!(ct.attributes.len(), 2); + assert!(ct.attributes[0].required); + assert!(!ct.attributes[1].required); + } else { + panic!("expected complex type"); + } + } + + #[test] + fn test_parse_schema_with_target_namespace() { + let schema = make_schema( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="http://example.com/ns"> + <xs:element name="root" type="xs:string"/> + </xs:schema>"#, + ); + assert_eq!( + schema.target_namespace.as_deref(), + Some("http://example.com/ns") + ); + } + + #[test] + fn test_parse_schema_with_nested_complex_types() { + let schema = make_schema( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="order"><xs:complexType><xs:sequence> + <xs:element name="item"><xs:complexType><xs:sequence> + <xs:element name="name" type="xs:string"/> + <xs:element name="qty" type="xs:integer"/> + </xs:sequence></xs:complexType></xs:element> + </xs:sequence></xs:complexType></xs:element> + </xs:schema>"#, + ); + if let Some(XsdType::Complex(ct)) = &schema.elements["order"].inline_type { + if let ComplexContent::Sequence(p) = &ct.content { + if let XsdParticle::Element(item) = &p[0] { + assert_eq!(item.name, "item"); + assert!(item.inline_type.is_some()); + } else { + panic!("expected element"); + } + } else { + panic!("expected sequence"); + } + } else { + panic!("expected complex type"); + } + } + + #[test] + fn test_validate_valid_document() { + let r = validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="greeting" type="xs:string"/> + </xs:schema>"#, + "<greeting>Hello World</greeting>", + ); + assert!(r.is_valid, "errors: {:?}", r.errors); + } + + #[test] + fn test_validate_invalid_missing_required_element() { + let r = validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="person"><xs:complexType><xs:sequence> + <xs:element name="name" type="xs:string"/> + <xs:element name="age" type="xs:integer"/> + </xs:sequence></xs:complexType></xs:element> + </xs:schema>"#, + "<person><name>Alice</name></person>", + ); + assert!(!r.is_valid); + assert!(r.errors.iter().any(|e| e.message.contains("age"))); + } + + #[test] + fn test_validate_invalid_wrong_order_sequence() { + let r = validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="person"><xs:complexType><xs:sequence> + <xs:element name="name" type="xs:string"/> + <xs:element name="age" type="xs:integer"/> + </xs:sequence></xs:complexType></xs:element> + </xs:schema>"#, + "<person><age>30</age><name>Alice</name></person>", + ); + assert!(!r.is_valid); + } + + #[test] + fn test_validate_invalid_too_many_occurrences() { + let r = validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="root"><xs:complexType><xs:sequence> + <xs:element name="item" type="xs:string" maxOccurs="2"/> + </xs:sequence></xs:complexType></xs:element> + </xs:schema>"#, + "<root><item>a</item><item>b</item><item>c</item></root>", + ); + assert!(!r.is_valid); + assert!( + r.errors.iter().any(|e| e.message.contains("item")), + "errors: {:?}", + r.errors + ); + } + + #[test] + fn test_validate_invalid_missing_required_attribute() { + let r = validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="item"><xs:complexType><xs:sequence> + <xs:element name="name" type="xs:string"/> + </xs:sequence> + <xs:attribute name="id" type="xs:integer" use="required"/> + </xs:complexType></xs:element> + </xs:schema>"#, + "<item><name>Test</name></item>", + ); + assert!(!r.is_valid); + assert!(r + .errors + .iter() + .any(|e| e.message.contains("required attribute"))); + } + + #[test] + fn test_validate_invalid_wrong_attribute_type() { + let r = validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="item"><xs:complexType><xs:sequence> + <xs:element name="name" type="xs:string"/> + </xs:sequence> + <xs:attribute name="count" type="xs:integer"/> + </xs:complexType></xs:element> + </xs:schema>"#, + r#"<item count="abc"><name>Test</name></item>"#, + ); + assert!(!r.is_valid); + assert!(r.errors.iter().any(|e| e.message.contains("integer"))); + } + + #[test] + fn test_validate_builtin_type_integer() { + assert!( + validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="c" type="xs:integer"/></xs:schema>"#, + "<c>42</c>" + ) + .is_valid + ); + assert!( + !validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="c" type="xs:integer"/></xs:schema>"#, + "<c>abc</c>" + ) + .is_valid + ); + } + + #[test] + fn test_validate_builtin_type_boolean() { + assert!( + validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="f" type="xs:boolean"/></xs:schema>"#, + "<f>true</f>" + ) + .is_valid + ); + assert!( + validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="f" type="xs:boolean"/></xs:schema>"#, + "<f>0</f>" + ) + .is_valid + ); + assert!( + !validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="f" type="xs:boolean"/></xs:schema>"#, + "<f>yes</f>" + ) + .is_valid + ); + } + + #[test] + fn test_validate_builtin_type_decimal() { + assert!( + validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="p" type="xs:decimal"/></xs:schema>"#, + "<p>19.99</p>" + ) + .is_valid + ); + assert!( + !validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="p" type="xs:decimal"/></xs:schema>"#, + "<p>abc</p>" + ) + .is_valid + ); + } + + #[test] + fn test_validate_string_facets_min_max_length() { + let xsd = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:simpleType name="nameType"><xs:restriction base="xs:string"> + <xs:minLength value="2"/><xs:maxLength value="10"/> + </xs:restriction></xs:simpleType> + <xs:element name="name" type="nameType"/> + </xs:schema>"#; + assert!(validate(xsd, "<name>Alice</name>").is_valid); + let short = validate(xsd, "<name>A</name>"); + assert!(!short.is_valid); + assert!(short.errors.iter().any(|e| e.message.contains("minLength"))); + let long = validate(xsd, "<name>Alexandrina Rose</name>"); + assert!(!long.is_valid); + assert!(long.errors.iter().any(|e| e.message.contains("maxLength"))); + } + + #[test] + fn test_validate_string_facets_pattern() { + let xsd = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:simpleType name="zipType"><xs:restriction base="xs:string"> + <xs:pattern value="\d\d\d\d\d"/> + </xs:restriction></xs:simpleType> + <xs:element name="zip" type="zipType"/> + </xs:schema>"#; + assert!(validate(xsd, "<zip>12345</zip>").is_valid); + assert!(!validate(xsd, "<zip>1234</zip>").is_valid); + assert!(!validate(xsd, "<zip>abcde</zip>").is_valid); + } + + #[test] + fn test_validate_numeric_facets_min_max_inclusive() { + let xsd = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:simpleType name="ageType"><xs:restriction base="xs:integer"> + <xs:minInclusive value="0"/><xs:maxInclusive value="150"/> + </xs:restriction></xs:simpleType> + <xs:element name="age" type="ageType"/> + </xs:schema>"#; + assert!(validate(xsd, "<age>25</age>").is_valid); + assert!(validate(xsd, "<age>0</age>").is_valid); + assert!(!validate(xsd, "<age>-1</age>").is_valid); + assert!(!validate(xsd, "<age>200</age>").is_valid); + } + + #[test] + fn test_validate_enumeration() { + let xsd = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:simpleType name="colorType"><xs:restriction base="xs:string"> + <xs:enumeration value="red"/><xs:enumeration value="green"/><xs:enumeration value="blue"/> + </xs:restriction></xs:simpleType> + <xs:element name="color" type="colorType"/> + </xs:schema>"#; + assert!(validate(xsd, "<color>red</color>").is_valid); + let r = validate(xsd, "<color>yellow</color>"); + assert!(!r.is_valid); + assert!(r.errors.iter().any(|e| e.message.contains("enumeration"))); + } + + #[test] + fn test_validate_mixed_content() { + let r = validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="para"><xs:complexType mixed="true"><xs:sequence> + <xs:element name="b" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> + </xs:sequence></xs:complexType></xs:element> + </xs:schema>"#, + "<para>Hello <b>world</b> end</para>", + ); + assert!(r.is_valid, "errors: {:?}", r.errors); + } + + #[test] + fn test_validate_choice_content_model() { + let xsd = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="pet"><xs:complexType><xs:choice> + <xs:element name="cat" type="xs:string"/> + <xs:element name="dog" type="xs:string"/> + </xs:choice></xs:complexType></xs:element> + </xs:schema>"#; + assert!(validate(xsd, "<pet><cat>Whiskers</cat></pet>").is_valid); + assert!(validate(xsd, "<pet><dog>Rex</dog></pet>").is_valid); + assert!(!validate(xsd, "<pet><fish>Nemo</fish></pet>").is_valid); + } + + #[test] + fn test_validate_optional_element() { + let xsd = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="person"><xs:complexType><xs:sequence> + <xs:element name="name" type="xs:string"/> + <xs:element name="email" type="xs:string" minOccurs="0"/> + </xs:sequence></xs:complexType></xs:element> + </xs:schema>"#; + assert!(validate(xsd, "<person><name>Alice</name><email>a@b</email></person>").is_valid); + let r = validate(xsd, "<person><name>Alice</name></person>"); + assert!(r.is_valid, "errors: {:?}", r.errors); + } + + #[test] + fn test_validate_unbounded_element() { + let r = validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="list"><xs:complexType><xs:sequence> + <xs:element name="item" type="xs:string" maxOccurs="unbounded"/> + </xs:sequence></xs:complexType></xs:element> + </xs:schema>"#, + "<list><item>a</item><item>b</item><item>c</item><item>d</item></list>", + ); + assert!(r.is_valid, "errors: {:?}", r.errors); + } + + #[test] + fn test_validate_undeclared_root_element() { + let r = validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="root" type="xs:string"/> + </xs:schema>"#, + "<unknown>text</unknown>", + ); + assert!(!r.is_valid); + assert!(r.errors.iter().any(|e| e.message.contains("not declared"))); + } + + #[test] + fn test_validate_empty_content_model() { + assert!( + validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="br"><xs:complexType/></xs:element> + </xs:schema>"#, + "<br/>" + ) + .is_valid + ); + assert!( + !validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="br"><xs:complexType/></xs:element> + </xs:schema>"#, + "<br>text</br>" + ) + .is_valid + ); + } + + #[test] + fn test_validate_fixed_attribute_value() { + assert!( + validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="item"><xs:complexType> + <xs:attribute name="version" type="xs:string" fixed="1.0"/> + </xs:complexType></xs:element> + </xs:schema>"#, + r#"<item version="1.0"/>"# + ) + .is_valid + ); + let r = validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="item"><xs:complexType> + <xs:attribute name="version" type="xs:string" fixed="1.0"/> + </xs:complexType></xs:element> + </xs:schema>"#, + r#"<item version="2.0"/>"#, + ); + assert!(!r.is_valid); + assert!(r.errors.iter().any(|e| e.message.contains("fixed"))); + } + + #[test] + fn test_validate_simple_content_extension() { + let r = validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:complexType name="priceType"><xs:simpleContent> + <xs:extension base="xs:decimal"> + <xs:attribute name="currency" type="xs:string" use="required"/> + </xs:extension> + </xs:simpleContent></xs:complexType> + <xs:element name="price" type="priceType"/> + </xs:schema>"#, + r#"<price currency="USD">19.99</price>"#, + ); + assert!(r.is_valid, "errors: {:?}", r.errors); + } + + #[test] + fn test_validate_date_types() { + assert!( + validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="d" type="xs:date"/></xs:schema>"#, + "<d>2024-01-15</d>" + ) + .is_valid + ); + assert!( + !validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="d" type="xs:date"/></xs:schema>"#, + "<d>not-a-date</d>" + ) + .is_valid + ); + assert!( + validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="dt" type="xs:dateTime"/></xs:schema>"#, + "<dt>2024-01-15T10:30:00</dt>" + ) + .is_valid + ); + assert!( + validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="t" type="xs:time"/></xs:schema>"#, + "<t>10:30:00</t>" + ) + .is_valid + ); + } + + #[test] + fn test_validate_all_content_model() { + let xsd = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="config"><xs:complexType><xs:all> + <xs:element name="host" type="xs:string"/> + <xs:element name="port" type="xs:integer"/> + </xs:all></xs:complexType></xs:element> + </xs:schema>"#; + assert!( + validate( + xsd, + "<config><host>localhost</host><port>8080</port></config>" + ) + .is_valid + ); + assert!( + validate( + xsd, + "<config><port>8080</port><host>localhost</host></config>" + ) + .is_valid + ); + } + + #[test] + fn test_parse_xsd_invalid_xml() { + assert!(parse_xsd("<not valid xml<<<").is_err()); + } + + #[test] + fn test_parse_xsd_wrong_root_element() { + assert!( + parse_xsd(r#"<xs:element xmlns:xs="http://www.w3.org/2001/XMLSchema" name="x"/>"#) + .is_err() + ); + } + + #[test] + fn test_validate_named_complex_type() { + let r = validate( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:complexType name="addressType"><xs:sequence> + <xs:element name="street" type="xs:string"/> + <xs:element name="city" type="xs:string"/> + </xs:sequence></xs:complexType> + <xs:element name="address" type="addressType"/> + </xs:schema>"#, + "<address><street>123 Main St</street><city>Springfield</city></address>", + ); + assert!(r.is_valid, "errors: {:?}", r.errors); + } + + #[test] + fn test_whitespace_preserve() { + use super::apply_whitespace_normalization; + use super::WhiteSpaceValue; + let result = apply_whitespace_normalization(" hello\tworld\n", &WhiteSpaceValue::Preserve); + assert_eq!(result, " hello\tworld\n"); + } + + #[test] + fn test_whitespace_replace() { + use super::apply_whitespace_normalization; + use super::WhiteSpaceValue; + let result = apply_whitespace_normalization("a\tb\nc\r", &WhiteSpaceValue::Replace); + assert_eq!(result, "a b c "); + } + + #[test] + fn test_whitespace_collapse() { + use super::apply_whitespace_normalization; + use super::WhiteSpaceValue; + let result = + apply_whitespace_normalization(" hello \t world \n ", &WhiteSpaceValue::Collapse); + assert_eq!(result, "hello world"); + } + + // ----------------------------------------------------------------------- + // Phase 0: Prefix map and QName resolution infrastructure + // ----------------------------------------------------------------------- + + #[test] + fn test_build_prefix_map() { + let doc = Document::parse_str( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + xmlns:tns="http://example.com/types" + targetNamespace="http://example.com/types"> + <xs:element name="root" type="xs:string"/> + </xs:schema>"#, + ) + .unwrap(); + let root = doc.root_element().unwrap(); + let map = build_prefix_map(&doc, root); + assert_eq!( + map.get("xs"), + Some(&"http://www.w3.org/2001/XMLSchema".to_string()) + ); + assert_eq!( + map.get("tns"), + Some(&"http://example.com/types".to_string()) + ); + } + + #[test] + fn test_resolve_type_qname_builtin() { + let mut map = HashMap::new(); + map.insert( + "xs".to_string(), + "http://www.w3.org/2001/XMLSchema".to_string(), + ); + let (ns, local) = resolve_type_qname("xs:string", &map); + assert_eq!(ns.as_deref(), Some("http://www.w3.org/2001/XMLSchema")); + assert_eq!(local, "string"); + } + + #[test] + fn test_resolve_type_qname_local() { + let mut map = HashMap::new(); + map.insert("tns".to_string(), "http://example.com/types".to_string()); + let (ns, local) = resolve_type_qname("tns:MyType", &map); + assert_eq!(ns.as_deref(), Some("http://example.com/types")); + assert_eq!(local, "MyType"); + } + + #[test] + fn test_resolve_type_qname_unprefixed() { + let map = HashMap::new(); + let (ns, local) = resolve_type_qname("MyType", &map); + assert_eq!(ns, None); + assert_eq!(local, "MyType"); + } + + // ----------------------------------------------------------------------- + // Phase 1: xsd:include tests + // ----------------------------------------------------------------------- + + fn make_resolver(schemas: Vec<(&str, &str)>) -> impl SchemaResolver { + let map: HashMap<String, String> = schemas + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + move |location: &str, _base: Option<&str>| map.get(location).cloned() + } + + #[test] + fn test_include_ignored_without_resolver() { + // Without a resolver, include is silently skipped (backward compat) + let schema = parse_xsd( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:include schemaLocation="types.xsd"/> + <xs:element name="root" type="xs:string"/> + </xs:schema>"#, + ) + .unwrap(); + assert!(schema.elements.contains_key("root")); + } + + #[test] + fn test_include_merges_types() { + let resolver = make_resolver(vec![( + "types.xsd", + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:complexType name="PersonType"><xs:sequence> + <xs:element name="name" type="xs:string"/> + </xs:sequence></xs:complexType> + </xs:schema>"#, + )]); + let opts = XsdParseOptions { + resolver: Some(&resolver), + base_uri: None, + }; + let schema = parse_xsd_with_options( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:include schemaLocation="types.xsd"/> + <xs:element name="person" type="PersonType"/> + </xs:schema>"#, + &opts, + ) + .unwrap(); + assert!(schema.types.contains_key("PersonType")); + assert!(schema.elements.contains_key("person")); + } + + #[test] + fn test_include_merges_elements() { + let resolver = make_resolver(vec![( + "elements.xsd", + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="greeting" type="xs:string"/> + </xs:schema>"#, + )]); + let opts = XsdParseOptions { + resolver: Some(&resolver), + base_uri: None, + }; + let schema = parse_xsd_with_options( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:include schemaLocation="elements.xsd"/> + <xs:element name="root" type="xs:string"/> + </xs:schema>"#, + &opts, + ) + .unwrap(); + assert!(schema.elements.contains_key("greeting")); + assert!(schema.elements.contains_key("root")); + } + + #[test] + fn test_include_chameleon() { + // Included schema has no targetNamespace — adopts includer's namespace + let resolver = make_resolver(vec![( + "types.xsd", + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:complexType name="AddrType"><xs:sequence> + <xs:element name="street" type="xs:string"/> + </xs:sequence></xs:complexType> + </xs:schema>"#, + )]); + let opts = XsdParseOptions { + resolver: Some(&resolver), + base_uri: None, + }; + let schema = parse_xsd_with_options( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + targetNamespace="http://example.com/main"> + <xs:include schemaLocation="types.xsd"/> + <xs:element name="addr" type="AddrType"/> + </xs:schema>"#, + &opts, + ) + .unwrap(); + // The type should be merged into the main schema + assert!(schema.types.contains_key("AddrType")); + } + + #[test] + fn test_include_namespace_mismatch_error() { + let resolver = make_resolver(vec![( + "other.xsd", + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + targetNamespace="http://other.com"> + <xs:element name="x" type="xs:string"/> + </xs:schema>"#, + )]); + let opts = XsdParseOptions { + resolver: Some(&resolver), + base_uri: None, + }; + let result = parse_xsd_with_options( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + targetNamespace="http://example.com"> + <xs:include schemaLocation="other.xsd"/> + </xs:schema>"#, + &opts, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().message.contains("namespace")); + } + + #[test] + fn test_include_cycle_detection() { + // A includes B, B includes A — should not loop + let resolver = make_resolver(vec![ + ( + "a.xsd", + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:include schemaLocation="b.xsd"/> + <xs:element name="a" type="xs:string"/> + </xs:schema>"#, + ), + ( + "b.xsd", + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:include schemaLocation="a.xsd"/> + <xs:element name="b" type="xs:string"/> + </xs:schema>"#, + ), + ]); + let opts = XsdParseOptions { + resolver: Some(&resolver), + base_uri: None, + }; + // Parse from a.xsd content — should include b.xsd but not re-include a.xsd + let schema = parse_xsd_with_options( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:include schemaLocation="a.xsd"/> + <xs:element name="root" type="xs:string"/> + </xs:schema>"#, + &opts, + ) + .unwrap(); + assert!(schema.elements.contains_key("root")); + assert!(schema.elements.contains_key("a")); + assert!(schema.elements.contains_key("b")); + } + + #[test] + fn test_include_transitive() { + // A includes B, B includes C — declarations from C available in A + let resolver = make_resolver(vec![ + ( + "b.xsd", + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:include schemaLocation="c.xsd"/> + <xs:element name="b" type="xs:string"/> + </xs:schema>"#, + ), + ( + "c.xsd", + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:complexType name="CType"><xs:sequence> + <xs:element name="val" type="xs:string"/> + </xs:sequence></xs:complexType> + </xs:schema>"#, + ), + ]); + let opts = XsdParseOptions { + resolver: Some(&resolver), + base_uri: None, + }; + let schema = parse_xsd_with_options( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:include schemaLocation="b.xsd"/> + <xs:element name="root" type="CType"/> + </xs:schema>"#, + &opts, + ) + .unwrap(); + assert!(schema.elements.contains_key("root")); + assert!(schema.elements.contains_key("b")); + assert!(schema.types.contains_key("CType")); + } + + #[test] + fn test_include_resolver_returns_none() { + let resolver = make_resolver(vec![]); + let opts = XsdParseOptions { + resolver: Some(&resolver), + base_uri: None, + }; + let result = parse_xsd_with_options( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:include schemaLocation="nonexistent.xsd"/> + </xs:schema>"#, + &opts, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().message.contains("nonexistent.xsd")); + } + + // ----------------------------------------------------------------------- + // Phase 2: xsd:import tests + // ----------------------------------------------------------------------- + + #[test] + fn test_import_cross_namespace_type() { + let resolver = make_resolver(vec![( + "types.xsd", + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + targetNamespace="http://example.com/types"> + <xs:complexType name="AddressType"><xs:sequence> + <xs:element name="street" type="xs:string"/> + <xs:element name="city" type="xs:string"/> + </xs:sequence></xs:complexType> + </xs:schema>"#, + )]); + let opts = XsdParseOptions { + resolver: Some(&resolver), + base_uri: None, + }; + let schema = parse_xsd_with_options( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + xmlns:tns="http://example.com/types" + targetNamespace="http://example.com/main"> + <xs:import namespace="http://example.com/types" schemaLocation="types.xsd"/> + <xs:element name="address" type="tns:AddressType"/> + </xs:schema>"#, + &opts, + ) + .unwrap(); + // The imported type should be resolvable + assert!(schema + .imported_namespaces + .contains_key("http://example.com/types")); + let imported = &schema.imported_namespaces["http://example.com/types"]; + assert!(imported.types.contains_key("AddressType")); + } + + #[test] + fn test_import_namespace_mismatch_error() { + let resolver = make_resolver(vec![( + "types.xsd", + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + targetNamespace="http://wrong.com"> + <xs:element name="x" type="xs:string"/> + </xs:schema>"#, + )]); + let opts = XsdParseOptions { + resolver: Some(&resolver), + base_uri: None, + }; + let result = parse_xsd_with_options( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:import namespace="http://expected.com" schemaLocation="types.xsd"/> + </xs:schema>"#, + &opts, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().message.contains("namespace")); + } + + #[test] + fn test_import_without_schema_location() { + // Import with just namespace attribute is valid (declares expected ns) + let schema = parse_xsd( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:import namespace="http://example.com/types"/> + <xs:element name="root" type="xs:string"/> + </xs:schema>"#, + ) + .unwrap(); + assert!(schema.elements.contains_key("root")); + } + + #[test] + fn test_import_cycle_detection() { + let resolver = make_resolver(vec![ + ( + "a.xsd", + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + targetNamespace="http://example.com/a"> + <xs:import namespace="http://example.com/b" schemaLocation="b.xsd"/> + <xs:element name="a" type="xs:string"/> + </xs:schema>"#, + ), + ( + "b.xsd", + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + targetNamespace="http://example.com/b"> + <xs:import namespace="http://example.com/a" schemaLocation="a.xsd"/> + <xs:element name="b" type="xs:string"/> + </xs:schema>"#, + ), + ]); + let opts = XsdParseOptions { + resolver: Some(&resolver), + base_uri: None, + }; + let schema = parse_xsd_with_options( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + targetNamespace="http://example.com/main"> + <xs:import namespace="http://example.com/a" schemaLocation="a.xsd"/> + <xs:element name="root" type="xs:string"/> + </xs:schema>"#, + &opts, + ) + .unwrap(); + assert!(schema.elements.contains_key("root")); + assert!(schema + .imported_namespaces + .contains_key("http://example.com/a")); + } + + #[test] + fn test_import_multiple_namespaces() { + let resolver = make_resolver(vec![ + ( + "types.xsd", + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + targetNamespace="http://example.com/types"> + <xs:complexType name="NameType"><xs:sequence> + <xs:element name="first" type="xs:string"/> + </xs:sequence></xs:complexType> + </xs:schema>"#, + ), + ( + "addr.xsd", + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + targetNamespace="http://example.com/addr"> + <xs:complexType name="AddrType"><xs:sequence> + <xs:element name="city" type="xs:string"/> + </xs:sequence></xs:complexType> + </xs:schema>"#, + ), + ]); + let opts = XsdParseOptions { + resolver: Some(&resolver), + base_uri: None, + }; + let schema = parse_xsd_with_options( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + xmlns:t="http://example.com/types" + xmlns:a="http://example.com/addr"> + <xs:import namespace="http://example.com/types" schemaLocation="types.xsd"/> + <xs:import namespace="http://example.com/addr" schemaLocation="addr.xsd"/> + <xs:element name="root" type="xs:string"/> + </xs:schema>"#, + &opts, + ) + .unwrap(); + assert!(schema + .imported_namespaces + .contains_key("http://example.com/types")); + assert!(schema + .imported_namespaces + .contains_key("http://example.com/addr")); + assert!(schema.imported_namespaces["http://example.com/types"] + .types + .contains_key("NameType")); + assert!(schema.imported_namespaces["http://example.com/addr"] + .types + .contains_key("AddrType")); + } + + #[test] + fn test_import_and_include_combined() { + let resolver = make_resolver(vec![ + ( + "local_types.xsd", + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:complexType name="LocalType"><xs:sequence> + <xs:element name="value" type="xs:string"/> + </xs:sequence></xs:complexType> + </xs:schema>"#, + ), + ( + "foreign.xsd", + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + targetNamespace="http://foreign.com"> + <xs:complexType name="ForeignType"><xs:sequence> + <xs:element name="data" type="xs:string"/> + </xs:sequence></xs:complexType> + </xs:schema>"#, + ), + ]); + let opts = XsdParseOptions { + resolver: Some(&resolver), + base_uri: None, + }; + let schema = parse_xsd_with_options( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + xmlns:f="http://foreign.com"> + <xs:include schemaLocation="local_types.xsd"/> + <xs:import namespace="http://foreign.com" schemaLocation="foreign.xsd"/> + <xs:element name="root" type="LocalType"/> + </xs:schema>"#, + &opts, + ) + .unwrap(); + assert!(schema.types.contains_key("LocalType")); + assert!(schema + .imported_namespaces + .contains_key("http://foreign.com")); + assert!(schema.imported_namespaces["http://foreign.com"] + .types + .contains_key("ForeignType")); + } + + // ----------------------------------------------------------------------- + // Phase 3: Namespace-aware validation tests + // ----------------------------------------------------------------------- + + #[test] + fn test_validate_with_imported_types() { + // End-to-end: parse multi-schema, validate document + let resolver = make_resolver(vec![( + "types.xsd", + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + targetNamespace="http://example.com/types"> + <xs:complexType name="AddressType"><xs:sequence> + <xs:element name="street" type="xs:string"/> + <xs:element name="city" type="xs:string"/> + </xs:sequence></xs:complexType> + </xs:schema>"#, + )]); + let opts = XsdParseOptions { + resolver: Some(&resolver), + base_uri: None, + }; + let schema = parse_xsd_with_options( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + xmlns:tns="http://example.com/types"> + <xs:import namespace="http://example.com/types" schemaLocation="types.xsd"/> + <xs:element name="address" type="tns:AddressType"/> + </xs:schema>"#, + &opts, + ) + .unwrap(); + + let doc = Document::parse_str( + "<address><street>123 Main</street><city>Springfield</city></address>", + ) + .unwrap(); + let result = validate_xsd(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_validate_imported_content_model() { + // Validate that child elements typed from imported schemas validate + let resolver = make_resolver(vec![( + "types.xsd", + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + targetNamespace="http://example.com/types"> + <xs:complexType name="NameType"><xs:sequence> + <xs:element name="first" type="xs:string"/> + <xs:element name="last" type="xs:string"/> + </xs:sequence></xs:complexType> + </xs:schema>"#, + )]); + let opts = XsdParseOptions { + resolver: Some(&resolver), + base_uri: None, + }; + let schema = parse_xsd_with_options( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + xmlns:t="http://example.com/types"> + <xs:import namespace="http://example.com/types" schemaLocation="types.xsd"/> + <xs:element name="person"><xs:complexType><xs:sequence> + <xs:element name="name" type="t:NameType"/> + <xs:element name="age" type="xs:integer"/> + </xs:sequence></xs:complexType></xs:element> + </xs:schema>"#, + &opts, + ) + .unwrap(); + + // Valid document + let doc = Document::parse_str( + "<person><name><first>John</first><last>Doe</last></name><age>30</age></person>", + ) + .unwrap(); + let result = validate_xsd(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + + // Invalid document: wrong child element in imported type + let doc = Document::parse_str( + "<person><name><wrong>X</wrong><last>Doe</last></name><age>30</age></person>", + ) + .unwrap(); + let result = validate_xsd(&doc, &schema); + assert!(!result.is_valid); + } + + #[test] + fn test_validate_included_type_validation() { + // Validate that included types work in validation too + let resolver = make_resolver(vec![( + "types.xsd", + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:complexType name="ItemType"><xs:sequence> + <xs:element name="name" type="xs:string"/> + <xs:element name="qty" type="xs:integer"/> + </xs:sequence></xs:complexType> + </xs:schema>"#, + )]); + let opts = XsdParseOptions { + resolver: Some(&resolver), + base_uri: None, + }; + let schema = parse_xsd_with_options( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:include schemaLocation="types.xsd"/> + <xs:element name="item" type="ItemType"/> + </xs:schema>"#, + &opts, + ) + .unwrap(); + + let doc = Document::parse_str("<item><name>Widget</name><qty>5</qty></item>").unwrap(); + let result = validate_xsd(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + // ----------------------------------------------------------------------- + // Element ref support tests + // ----------------------------------------------------------------------- + + #[test] + fn test_element_ref_local() { + // ref to a global element in the same schema + let schema = parse_xsd( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="name" type="xs:string"/> + <xs:element name="person"> + <xs:complexType><xs:sequence> + <xs:element ref="name"/> + </xs:sequence></xs:complexType> + </xs:element> + </xs:schema>"#, + ) + .unwrap(); + + let doc = Document::parse_str("<person><name>Alice</name></person>").unwrap(); + let result = validate_xsd(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + } + + #[test] + fn test_element_ref_imported() { + // ref to a global element in an imported namespace (UBL pattern) + let resolver = make_resolver(vec![( + "components.xsd", + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + targetNamespace="http://example.com/components"> + <xs:element name="ID" type="xs:string"/> + <xs:element name="Name" type="xs:string"/> + </xs:schema>"#, + )]); + let opts = XsdParseOptions { + resolver: Some(&resolver), + base_uri: None, + }; + let schema = parse_xsd_with_options( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + xmlns:cbc="http://example.com/components"> + <xs:import namespace="http://example.com/components" + schemaLocation="components.xsd"/> + <xs:element name="Order"> + <xs:complexType><xs:sequence> + <xs:element ref="cbc:ID"/> + <xs:element ref="cbc:Name" minOccurs="0"/> + </xs:sequence></xs:complexType> + </xs:element> + </xs:schema>"#, + &opts, + ) + .unwrap(); + + let doc = Document::parse_str("<Order><ID>ORD-1</ID><Name>Test</Name></Order>").unwrap(); + let result = validate_xsd(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + + // Valid without optional Name + let doc2 = Document::parse_str("<Order><ID>ORD-2</ID></Order>").unwrap(); + let result2 = validate_xsd(&doc2, &schema); + assert!(result2.is_valid, "errors: {:?}", result2.errors); + + // Invalid: wrong element + let doc3 = Document::parse_str("<Order><Wrong>X</Wrong></Order>").unwrap(); + let result3 = validate_xsd(&doc3, &schema); + assert!(!result3.is_valid); + } + + #[test] + fn test_element_ref_with_occurs() { + // ref with minOccurs/maxOccurs overrides + let schema = parse_xsd( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="item" type="xs:string"/> + <xs:element name="list"> + <xs:complexType><xs:sequence> + <xs:element ref="item" minOccurs="1" maxOccurs="unbounded"/> + </xs:sequence></xs:complexType> + </xs:element> + </xs:schema>"#, + ) + .unwrap(); + + let doc = Document::parse_str("<list><item>a</item><item>b</item></list>").unwrap(); + let result = validate_xsd(&doc, &schema); + assert!(result.is_valid, "errors: {:?}", result.errors); + + // Invalid: empty list (minOccurs=1) + let doc2 = Document::parse_str("<list/>").unwrap(); + let result2 = validate_xsd(&doc2, &schema); + assert!(!result2.is_valid); + } + + #[test] + fn test_element_form_default_qualified() { + let schema = parse_xsd( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + targetNamespace="urn:example" + xmlns:tns="urn:example" + elementFormDefault="qualified"> + <xs:element name="order"> + <xs:complexType><xs:sequence> + <xs:element name="item" type="xs:string"/> + </xs:sequence></xs:complexType> + </xs:element> + </xs:schema>"#, + ) + .unwrap(); + assert_eq!(schema.element_form_default, FormDefault::Qualified); + + // Valid: child element is namespace-qualified + let doc = Document::parse_str(r#"<order xmlns="urn:example"><item>Widget</item></order>"#) + .unwrap(); + let result = validate_xsd(&doc, &schema); + assert!( + result.is_valid, + "qualified children should pass: {:?}", + result.errors + ); + + // Invalid: child element is NOT namespace-qualified + let doc_fail = Document::parse_str( + r#"<tns:order xmlns:tns="urn:example"><item>Widget</item></tns:order>"#, + ) + .unwrap(); + let result = validate_xsd(&doc_fail, &schema); + assert!( + !result.is_valid, + "unqualified child should fail when elementFormDefault=qualified" + ); + } + + #[test] + fn test_element_form_default_unqualified() { + let schema = parse_xsd( + r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" + targetNamespace="urn:example" + xmlns:tns="urn:example"> + <xs:element name="order"> + <xs:complexType><xs:sequence> + <xs:element name="item" type="xs:string"/> + </xs:sequence></xs:complexType> + </xs:element> + </xs:schema>"#, + ) + .unwrap(); + assert_eq!(schema.element_form_default, FormDefault::Unqualified); + + // Valid: child element without namespace (unqualified is default) + let doc = Document::parse_str( + r#"<tns:order xmlns:tns="urn:example"><item>Widget</item></tns:order>"#, + ) + .unwrap(); + let result = validate_xsd(&doc, &schema); + assert!( + result.is_valid, + "unqualified children should pass: {:?}", + result.errors + ); + } +} diff --git a/browser/vendor/xmloxide/src/xinclude/mod.rs b/browser/vendor/xmloxide/src/xinclude/mod.rs new file mode 100644 index 000000000..a2e45c024 --- /dev/null +++ b/browser/vendor/xmloxide/src/xinclude/mod.rs @@ -0,0 +1,853 @@ +//! `XInclude` 1.0 processing. +//! +//! This module implements the [XML Inclusions (XInclude) 1.0](https://www.w3.org/TR/xinclude/) +//! specification. `XInclude` allows XML documents to reference and include content from +//! other XML or text resources using `xi:include` elements. +//! +//! # Overview +//! +//! `XInclude` processing replaces `<xi:include>` elements (in the +//! `http://www.w3.org/2001/XInclude` namespace) with the content they reference. +//! The `href` attribute specifies the URI of the resource to include, and the +//! `parse` attribute determines whether the content is included as parsed XML +//! (`parse="xml"`, the default) or as a text node (`parse="text"`). +//! +//! If a resource cannot be resolved, the processor looks for an `<xi:fallback>` +//! child element and uses its content instead. If no fallback is provided, the +//! include is recorded as an error. +//! +//! # Design +//! +//! Since the core library does not perform I/O, the caller provides a resolver +//! callback (`Fn(&str) -> Option<String>`) that maps URIs to content. This +//! allows the library to be used in any environment (filesystem, network, +//! in-memory test fixtures, etc.). + +use std::collections::HashSet; +use std::fmt; + +use crate::tree::{Document, NodeId, NodeKind}; + +/// The `XInclude` namespace URI. +/// +/// All `xi:include` and `xi:fallback` elements must be in this namespace +/// for `XInclude` processing to recognize them. +pub const XINCLUDE_NS: &str = "http://www.w3.org/2001/XInclude"; + +/// The local name of the include element. +const INCLUDE_ELEMENT: &str = "include"; + +/// The local name of the fallback element. +const FALLBACK_ELEMENT: &str = "fallback"; + +/// Options for `XInclude` processing. +/// +/// Controls the behavior of [`process_xincludes`], such as the maximum +/// nesting depth for recursive includes. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::xinclude::XIncludeOptions; +/// +/// let opts = XIncludeOptions::default(); +/// assert_eq!(opts.max_depth, 50); +/// ``` +#[derive(Debug, Clone)] +pub struct XIncludeOptions { + /// Maximum nesting depth for recursive includes. + /// + /// When an included document itself contains `xi:include` elements, + /// processing recurses. This limit prevents infinite recursion or + /// excessively deep include chains. The default is 50. + pub max_depth: usize, +} + +impl Default for XIncludeOptions { + fn default() -> Self { + Self { max_depth: 50 } + } +} + +/// An error encountered during `XInclude` processing. +/// +/// Errors are collected rather than stopping processing, so that as many +/// includes as possible are resolved even when some fail. +#[derive(Debug, Clone)] +pub struct XIncludeError { + /// Human-readable description of the error. + pub message: String, + /// The `href` that caused the error, if applicable. + pub href: Option<String>, +} + +impl fmt::Display for XIncludeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.href { + Some(href) => write!(f, "XInclude error for '{href}': {}", self.message), + None => write!(f, "XInclude error: {}", self.message), + } + } +} + +/// Result of `XInclude` processing. +/// +/// Contains the processed document with all resolvable includes expanded, +/// along with statistics and any errors encountered. +pub struct XIncludeResult { + /// Number of includes that were successfully processed. + pub inclusions: usize, + /// Errors encountered during processing. + /// + /// Each error corresponds to an `xi:include` element that could not + /// be resolved and had no usable `xi:fallback`. + pub errors: Vec<XIncludeError>, +} + +/// Processes `XInclude` elements in a document. +/// +/// Walks the document tree looking for elements in the `XInclude` namespace +/// (`http://www.w3.org/2001/XInclude`) with local name `include`. For each +/// such element: +/// +/// 1. The `href` attribute is read to determine the resource URI. +/// 2. The `parse` attribute is read to determine how to interpret the content +/// (`"xml"` or `"text"`, defaulting to `"xml"`). +/// 3. The `resolver` callback is called with the href (minus any fragment) to +/// obtain the resource content. +/// 4. On success, the `xi:include` element is replaced with the included content. +/// 5. On failure, the `xi:fallback` child is used if present; otherwise an error +/// is recorded. +/// +/// The resolver callback receives the href string and returns `Some(content)` if +/// the resource is available, or `None` if it cannot be resolved. +/// +/// # Circular inclusion detection +/// +/// The processor tracks which hrefs have been included in the current inclusion +/// chain and rejects any attempt to include an already-active href, preventing +/// infinite loops. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::Document; +/// use xmloxide::xinclude::{process_xincludes, XIncludeOptions}; +/// +/// let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"> +/// <xi:include href="greeting.xml"/> +/// </doc>"#; +/// +/// let mut doc = Document::parse_str(xml).unwrap(); +/// let result = process_xincludes(&mut doc, |href| { +/// match href { +/// "greeting.xml" => Some("<hello>world</hello>".to_string()), +/// _ => None, +/// } +/// }, &XIncludeOptions::default()); +/// +/// assert_eq!(result.inclusions, 1); +/// assert!(result.errors.is_empty()); +/// ``` +pub fn process_xincludes<F>( + doc: &mut Document, + resolver: F, + options: &XIncludeOptions, +) -> XIncludeResult +where + F: Fn(&str) -> Option<String>, +{ + let mut state = ProcessingState { + inclusions: 0, + errors: Vec::new(), + active_hrefs: HashSet::new(), + max_depth: options.max_depth, + }; + + process_node(doc, doc.root(), &resolver, &mut state, 0); + + XIncludeResult { + inclusions: state.inclusions, + errors: state.errors, + } +} + +/// Internal mutable state carried through the `XInclude` processing pass. +struct ProcessingState { + /// Number of successfully processed includes. + inclusions: usize, + /// Accumulated errors. + errors: Vec<XIncludeError>, + /// Set of hrefs currently in the inclusion chain (for cycle detection). + active_hrefs: HashSet<String>, + /// Maximum allowed nesting depth. + max_depth: usize, +} + +/// Recursively processes `XInclude` elements under the given node. +/// +/// We collect the list of children first (as a `Vec<NodeId>`) to avoid +/// borrowing issues while mutating the document. +fn process_node<F>( + doc: &mut Document, + node: NodeId, + resolver: &F, + state: &mut ProcessingState, + depth: usize, +) where + F: Fn(&str) -> Option<String>, +{ + // Collect children before iteration, since we may mutate the tree. + let children: Vec<NodeId> = doc.children(node).collect(); + + for child in children { + if is_xinclude_element(doc, child) { + process_include_element(doc, child, resolver, state, depth); + } else { + // Recurse into non-include elements to find nested xi:include. + process_node(doc, child, resolver, state, depth); + } + } +} + +/// Checks whether a node is an `xi:include` element in the `XInclude` namespace. +fn is_xinclude_element(doc: &Document, node: NodeId) -> bool { + if let NodeKind::Element { + name, namespace, .. + } = &doc.node(node).kind + { + name == INCLUDE_ELEMENT && namespace.as_deref() == Some(XINCLUDE_NS) + } else { + false + } +} + +/// Checks whether a node is an `xi:fallback` element in the `XInclude` namespace. +fn is_fallback_element(doc: &Document, node: NodeId) -> bool { + if let NodeKind::Element { + name, namespace, .. + } = &doc.node(node).kind + { + name == FALLBACK_ELEMENT && namespace.as_deref() == Some(XINCLUDE_NS) + } else { + false + } +} + +/// Processes a single `xi:include` element. +/// +/// Reads the `href` and `parse` attributes, resolves the content via the +/// resolver, and replaces the `xi:include` element with the result. +fn process_include_element<F>( + doc: &mut Document, + include_node: NodeId, + resolver: &F, + state: &mut ProcessingState, + depth: usize, +) where + F: Fn(&str) -> Option<String>, +{ + // Read attributes from the xi:include element. + let href = doc.attribute(include_node, "href").map(str::to_owned); + let parse = doc + .attribute(include_node, "parse") + .unwrap_or("xml") + .to_owned(); + + // Validate: href is required. + let Some(href) = href else { + state.errors.push(XIncludeError { + message: "xi:include element is missing required 'href' attribute".to_string(), + href: None, + }); + // Remove the xi:include element. + doc.detach(include_node); + return; + }; + + // Validate: parse must be "xml" or "text". + if parse != "xml" && parse != "text" { + state.errors.push(XIncludeError { + message: format!("invalid parse attribute value '{parse}'; expected 'xml' or 'text'"), + href: Some(href), + }); + doc.detach(include_node); + return; + } + + // Check depth limit. + if depth >= state.max_depth { + state.errors.push(XIncludeError { + message: format!( + "maximum XInclude nesting depth ({}) exceeded", + state.max_depth + ), + href: Some(href), + }); + doc.detach(include_node); + return; + } + + // Strip fragment identifier for resolution (but keep it for potential + // XPointer processing later). + let (base_href, _fragment) = split_fragment(&href); + + // Check for circular inclusion. + if state.active_hrefs.contains(base_href) { + state.errors.push(XIncludeError { + message: "circular inclusion detected".to_string(), + href: Some(href), + }); + doc.detach(include_node); + return; + } + + // Resolve the resource. + let content = resolver(base_href); + + match content { + Some(content) => { + // Mark this href as active in the inclusion chain. + state.active_hrefs.insert(base_href.to_owned()); + + let success = match parse.as_str() { + "xml" => process_xml_include(doc, include_node, &content, resolver, state, depth), + "text" => process_text_include(doc, include_node, &content), + _ => false, // Already validated above. + }; + + // Remove from active set after processing. + state.active_hrefs.remove(base_href); + + if success { + state.inclusions += 1; + } + } + None => { + // Resource not found — try fallback. + if !try_fallback(doc, include_node, resolver, state, depth) { + state.errors.push(XIncludeError { + message: "resource not found and no xi:fallback provided".to_string(), + href: Some(href), + }); + doc.detach(include_node); + } + } + } +} + +/// Processes an XML include: parses the content as XML and replaces the +/// `xi:include` element with the parsed children. +/// +/// Returns `true` on success. +fn process_xml_include<F>( + doc: &mut Document, + include_node: NodeId, + content: &str, + resolver: &F, + state: &mut ProcessingState, + depth: usize, +) -> bool +where + F: Fn(&str) -> Option<String>, +{ + // Parse the included content as an XML document. + let included_doc = match Document::parse_str(content) { + Ok(d) => d, + Err(e) => { + // Parse failure — try fallback, otherwise record error. + if try_fallback(doc, include_node, resolver, state, depth) { + return false; + } + state.errors.push(XIncludeError { + message: format!("failed to parse included XML: {e}"), + href: None, + }); + doc.detach(include_node); + return false; + } + }; + + // Copy nodes from the included document into the main document. + // We need to deep-copy because the nodes live in a different arena. + let included_root = included_doc.root(); + let included_children: Vec<NodeId> = included_doc.children(included_root).collect(); + + // Get the parent of the xi:include element so we can insert siblings. + let parent = doc.parent(include_node); + + // Insert each child of the included document's root before the + // xi:include element, then remove the xi:include element. + let mut inserted_nodes = Vec::new(); + for inc_child in &included_children { + let new_node = deep_copy_node(doc, &included_doc, *inc_child); + inserted_nodes.push(new_node); + } + + // Insert all new nodes before the include element. + for new_node in &inserted_nodes { + doc.insert_before(include_node, *new_node); + } + + // Detach and discard the xi:include element. + doc.detach(include_node); + + // Recursively process XInclude elements in the newly inserted content. + if parent.is_some() { + // We only need to process the newly inserted nodes. + for new_node in inserted_nodes { + process_node(doc, new_node, resolver, state, depth + 1); + } + } + + true +} + +/// Processes a text include: creates a text node with the content and replaces +/// the `xi:include` element. +/// +/// Returns `true` on success. +fn process_text_include(doc: &mut Document, include_node: NodeId, content: &str) -> bool { + let text_node = doc.create_node(NodeKind::Text { + content: content.to_string(), + }); + + doc.insert_before(include_node, text_node); + doc.detach(include_node); + + true +} + +/// Tries to use an `xi:fallback` child of the include element. +/// +/// If a fallback is found, its children are moved to replace the `xi:include` +/// element. Returns `true` if a fallback was found and applied. +fn try_fallback<F>( + doc: &mut Document, + include_node: NodeId, + resolver: &F, + state: &mut ProcessingState, + depth: usize, +) -> bool +where + F: Fn(&str) -> Option<String>, +{ + // Find the first xi:fallback child. + let fallback_node = { + let children: Vec<NodeId> = doc.children(include_node).collect(); + children + .into_iter() + .find(|&child| is_fallback_element(doc, child)) + }; + + let Some(fallback) = fallback_node else { + return false; + }; + + // Collect the fallback's children. + let fallback_children: Vec<NodeId> = doc.children(fallback).collect(); + + // Detach each fallback child and insert before the xi:include element. + let mut inserted_nodes = Vec::new(); + for child in fallback_children { + doc.detach(child); + doc.insert_before(include_node, child); + inserted_nodes.push(child); + } + + // Remove the xi:include element (which still contains the now-empty fallback). + doc.detach(include_node); + + // Recursively process the inserted fallback content. + for node in inserted_nodes { + process_node(doc, node, resolver, state, depth + 1); + } + + true +} + +/// Deep-copies a node (and all its descendants) from one document's arena +/// into another. +/// +/// This is necessary because nodes in different `Document`s live in separate +/// arenas and cannot share `NodeId`s. +fn deep_copy_node(target: &mut Document, source: &Document, source_id: NodeId) -> NodeId { + let source_node = source.node(source_id); + let new_id = target.create_node(source_node.kind.clone()); + + // Recursively copy children. + let children: Vec<NodeId> = source.children(source_id).collect(); + for child_id in children { + let new_child = deep_copy_node(target, source, child_id); + target.append_child(new_id, new_child); + } + + new_id +} + +/// Splits a URI into the base part and optional fragment identifier. +/// +/// For example, `"file.xml#section1"` returns `("file.xml", Some("section1"))`. +/// If there is no fragment, returns `(href, None)`. +fn split_fragment(href: &str) -> (&str, Option<&str>) { + if let Some(pos) = href.find('#') { + let (base, frag) = href.split_at(pos); + // frag starts with '#', skip it. + (base, Some(&frag[1..])) + } else { + (href, None) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + // Helper: parse XML, process XIncludes with the given resolver, return the + // document and result. + fn process_with_resolver<F>(xml: &str, resolver: F) -> (Document, XIncludeResult) + where + F: Fn(&str) -> Option<String>, + { + let mut doc = Document::parse_str(xml).unwrap(); + let result = process_xincludes(&mut doc, resolver, &XIncludeOptions::default()); + (doc, result) + } + + // Helper: serialize the document to a string for comparison. + fn doc_text_content(doc: &Document) -> String { + let root_elem = doc.root_element().unwrap(); + doc.text_content(root_elem) + } + + #[test] + fn test_basic_xml_include() { + let xml = + r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="inc.xml"/></doc>"#; + let (doc, result) = process_with_resolver(xml, |href| match href { + "inc.xml" => Some("<greeting>hello</greeting>".to_string()), + _ => None, + }); + + assert_eq!(result.inclusions, 1); + assert!(result.errors.is_empty()); + + // The included <greeting> element should be a child of <doc>. + let root = doc.root_element().unwrap(); + let children: Vec<NodeId> = doc.children(root).collect(); + assert_eq!(children.len(), 1); + assert_eq!(doc.node_name(children[0]), Some("greeting")); + assert_eq!(doc.text_content(children[0]), "hello"); + } + + #[test] + fn test_basic_text_include() { + let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="msg.txt" parse="text"/></doc>"#; + let (doc, result) = process_with_resolver(xml, |href| match href { + "msg.txt" => Some("Hello, World!".to_string()), + _ => None, + }); + + assert_eq!(result.inclusions, 1); + assert!(result.errors.is_empty()); + assert_eq!(doc_text_content(&doc), "Hello, World!"); + } + + #[test] + fn test_fallback_when_resource_not_found() { + let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="missing.xml"><xi:fallback><alt>fallback content</alt></xi:fallback></xi:include></doc>"#; + let (doc, result) = process_with_resolver(xml, |_| None); + + assert_eq!(result.inclusions, 0); + assert!(result.errors.is_empty()); + + let root = doc.root_element().unwrap(); + let children: Vec<NodeId> = doc.children(root).collect(); + assert_eq!(children.len(), 1); + assert_eq!(doc.node_name(children[0]), Some("alt")); + assert_eq!(doc.text_content(children[0]), "fallback content"); + } + + #[test] + fn test_fallback_with_text_content() { + let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="missing.xml"><xi:fallback>plain fallback</xi:fallback></xi:include></doc>"#; + let (doc, result) = process_with_resolver(xml, |_| None); + + assert_eq!(result.inclusions, 0); + assert!(result.errors.is_empty()); + assert_eq!(doc_text_content(&doc), "plain fallback"); + } + + #[test] + fn test_missing_href_attribute() { + let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include/></doc>"#; + let (_doc, result) = process_with_resolver(xml, |_| None); + + assert_eq!(result.inclusions, 0); + assert_eq!(result.errors.len(), 1); + assert!(result.errors[0].message.contains("missing required 'href'")); + assert!(result.errors[0].href.is_none()); + } + + #[test] + fn test_circular_inclusion_detection() { + // "a.xml" includes "b.xml" which includes "a.xml" again. + let xml = + r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="a.xml"/></doc>"#; + let (_, result) = process_with_resolver(xml, |href| match href { + "a.xml" => Some( + r#"<a xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="a.xml"/></a>"# + .to_string(), + ), + _ => None, + }); + + // The first include succeeds, the second (circular) fails. + assert_eq!(result.inclusions, 1); + assert_eq!(result.errors.len(), 1); + assert!(result.errors[0].message.contains("circular inclusion")); + } + + #[test] + fn test_max_depth_exceeded() { + let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="deep.xml"/></doc>"#; + let mut doc = Document::parse_str(xml).unwrap(); + let opts = XIncludeOptions { max_depth: 2 }; + + // Each level includes another level. + let result = process_xincludes( + &mut doc, + |href| { + match href { + "deep.xml" => Some( + r#"<level xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="deeper.xml"/></level>"# + .to_string(), + ), + "deeper.xml" => Some( + r#"<level xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="deepest.xml"/></level>"# + .to_string(), + ), + "deepest.xml" => Some("<leaf/>".to_string()), + _ => None, + } + }, + &opts, + ); + + // depth 0 -> deep.xml succeeds, depth 1 -> deeper.xml succeeds, + // depth 2 -> deepest.xml exceeds max_depth=2. + assert!(result.errors.iter().any(|e| e.message.contains("depth"))); + } + + #[test] + fn test_multiple_includes_in_same_document() { + let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="a.xml"/><xi:include href="b.xml"/></doc>"#; + let (doc, result) = process_with_resolver(xml, |href| match href { + "a.xml" => Some("<first/>".to_string()), + "b.xml" => Some("<second/>".to_string()), + _ => None, + }); + + assert_eq!(result.inclusions, 2); + assert!(result.errors.is_empty()); + + let root = doc.root_element().unwrap(); + let children: Vec<NodeId> = doc.children(root).collect(); + assert_eq!(children.len(), 2); + assert_eq!(doc.node_name(children[0]), Some("first")); + assert_eq!(doc.node_name(children[1]), Some("second")); + } + + #[test] + fn test_nested_includes() { + let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="outer.xml"/></doc>"#; + let (doc, result) = process_with_resolver(xml, |href| { + match href { + "outer.xml" => Some( + r#"<outer xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="inner.xml"/></outer>"# + .to_string(), + ), + "inner.xml" => Some("<inner>nested</inner>".to_string()), + _ => None, + } + }); + + assert_eq!(result.inclusions, 2); + assert!(result.errors.is_empty()); + + let root = doc.root_element().unwrap(); + let outer: Vec<NodeId> = doc.children(root).collect(); + assert_eq!(doc.node_name(outer[0]), Some("outer")); + + let inner: Vec<NodeId> = doc.children(outer[0]).collect(); + assert_eq!(doc.node_name(inner[0]), Some("inner")); + assert_eq!(doc.text_content(inner[0]), "nested"); + } + + #[test] + fn test_default_parse_attribute_is_xml() { + // When parse is not specified, it defaults to "xml". + let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="data.xml"/></doc>"#; + let (doc, result) = process_with_resolver(xml, |href| match href { + "data.xml" => Some("<item>value</item>".to_string()), + _ => None, + }); + + assert_eq!(result.inclusions, 1); + assert!(result.errors.is_empty()); + + let root = doc.root_element().unwrap(); + let children: Vec<NodeId> = doc.children(root).collect(); + assert_eq!(doc.node_name(children[0]), Some("item")); + } + + #[test] + fn test_include_replaces_entire_xi_include_element() { + // Verify that the xi:include element itself is completely removed. + let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><before/><xi:include href="mid.xml"/><after/></doc>"#; + let (doc, result) = process_with_resolver(xml, |href| match href { + "mid.xml" => Some("<middle/>".to_string()), + _ => None, + }); + + assert_eq!(result.inclusions, 1); + + let root = doc.root_element().unwrap(); + let names: Vec<Option<&str>> = doc.children(root).map(|c| doc.node_name(c)).collect(); + assert_eq!(names, vec![Some("before"), Some("middle"), Some("after")]); + } + + #[test] + fn test_text_include_preserves_whitespace() { + let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="ws.txt" parse="text"/></doc>"#; + let content = " line1\n line2\n"; + let (doc, result) = process_with_resolver(xml, |href| match href { + "ws.txt" => Some(content.to_string()), + _ => None, + }); + + assert_eq!(result.inclusions, 1); + assert_eq!(doc_text_content(&doc), content); + } + + #[test] + fn test_empty_include_content() { + // Including content that parses to an empty document root. + let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="empty.txt" parse="text"/></doc>"#; + let (doc, result) = process_with_resolver(xml, |href| match href { + "empty.txt" => Some(String::new()), + _ => None, + }); + + assert_eq!(result.inclusions, 1); + assert!(result.errors.is_empty()); + assert_eq!(doc_text_content(&doc), ""); + } + + #[test] + fn test_include_with_fragment_identifier() { + // Fragment identifiers are stripped for resolution; the base href + // is used to fetch the content. + let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="data.xml#section1"/></doc>"#; + let (doc, result) = process_with_resolver(xml, |href| match href { + "data.xml" => Some("<section>content</section>".to_string()), + _ => None, + }); + + assert_eq!(result.inclusions, 1); + assert!(result.errors.is_empty()); + + let root = doc.root_element().unwrap(); + let children: Vec<NodeId> = doc.children(root).collect(); + assert_eq!(doc.node_name(children[0]), Some("section")); + } + + #[test] + fn test_xinclude_namespace_detection() { + // An "include" element NOT in the XInclude namespace should be ignored. + let xml = r#"<doc><include href="should-ignore.xml"/></doc>"#; + let (_, result) = process_with_resolver(xml, |_| { + panic!("resolver should not be called for non-XInclude elements"); + }); + + assert_eq!(result.inclusions, 0); + assert!(result.errors.is_empty()); + } + + #[test] + fn test_split_fragment() { + assert_eq!(split_fragment("file.xml#sec"), ("file.xml", Some("sec"))); + assert_eq!(split_fragment("file.xml"), ("file.xml", None)); + assert_eq!(split_fragment("file.xml#"), ("file.xml", Some(""))); + assert_eq!(split_fragment("#frag"), ("", Some("frag"))); + } + + #[test] + fn test_no_fallback_records_error() { + let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="nope.xml"/></doc>"#; + let (_, result) = process_with_resolver(xml, |_| None); + + assert_eq!(result.inclusions, 0); + assert_eq!(result.errors.len(), 1); + assert!(result.errors[0].message.contains("resource not found")); + assert_eq!(result.errors[0].href.as_deref(), Some("nope.xml")); + } + + #[test] + fn test_invalid_parse_attribute() { + let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="x.xml" parse="json"/></doc>"#; + let (_, result) = process_with_resolver(xml, |_| None); + + assert_eq!(result.errors.len(), 1); + assert!(result.errors[0].message.contains("invalid parse attribute")); + } + + #[test] + fn test_xml_include_with_wrapper_element() { + // Included document has a root element with multiple children. + let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="multi.xml"/></doc>"#; + let (doc, result) = process_with_resolver(xml, |href| match href { + "multi.xml" => Some("<wrapper><first/><second/></wrapper>".to_string()), + _ => None, + }); + + assert_eq!(result.inclusions, 1); + assert!(result.errors.is_empty()); + + let root = doc.root_element().unwrap(); + let children: Vec<NodeId> = doc.children(root).collect(); + // The <wrapper> element is inserted as a child of <doc>. + assert_eq!(children.len(), 1); + assert_eq!(doc.node_name(children[0]), Some("wrapper")); + + let wrapper_children: Vec<NodeId> = doc.children(children[0]).collect(); + assert_eq!(wrapper_children.len(), 2); + assert_eq!(doc.node_name(wrapper_children[0]), Some("first")); + assert_eq!(doc.node_name(wrapper_children[1]), Some("second")); + } + + #[test] + fn test_options_default() { + let opts = XIncludeOptions::default(); + assert_eq!(opts.max_depth, 50); + } + + #[test] + fn test_error_display() { + let err = XIncludeError { + message: "resource not found".to_string(), + href: Some("file.xml".to_string()), + }; + assert_eq!( + err.to_string(), + "XInclude error for 'file.xml': resource not found" + ); + + let err_no_href = XIncludeError { + message: "bad element".to_string(), + href: None, + }; + assert_eq!(err_no_href.to_string(), "XInclude error: bad element"); + } +} diff --git a/browser/vendor/xmloxide/src/xpath/ast.rs b/browser/vendor/xmloxide/src/xpath/ast.rs new file mode 100644 index 000000000..0c73233b0 --- /dev/null +++ b/browser/vendor/xmloxide/src/xpath/ast.rs @@ -0,0 +1,427 @@ +//! Abstract syntax tree types for `XPath` 1.0 expressions. +//! +//! This module defines the AST that results from parsing an `XPath` expression +//! string. The AST closely follows the `XPath` 1.0 grammar from +//! <https://www.w3.org/TR/xpath-10/#section-Basics>. +//! +//! The primary type is [`Expr`], which represents any `XPath` expression. +//! Location paths are composed of [`Step`]s, each having an [`Axis`], +//! a [`NodeTest`], and zero or more predicate expressions. + +/// An `XPath` 1.0 expression. +/// +/// This enum represents the full range of `XPath` 1.0 expressions, including +/// literals, operators, function calls, and location paths. +/// +/// See `XPath` 1.0 section 3. +#[derive(Debug, Clone)] +pub enum Expr { + /// A numeric literal (e.g., `42`, `3.14`). + /// + /// See `XPath` 1.0 section 3.5. + Number(f64), + + /// A string literal (e.g., `"hello"` or `'world'`). + /// + /// See `XPath` 1.0 section 3.5. + String(String), + + /// A variable reference (e.g., `$foo`). + /// + /// The string contains the variable name without the leading `$`. + /// + /// See `XPath` 1.0 section 3.1. + Variable(String), + + /// A binary operation (e.g., `a + b`, `x = y`, `p and q`). + /// + /// See `XPath` 1.0 sections 3.3, 3.4, 3.5. + BinaryOp { + /// The operator. + op: BinaryOp, + /// The left-hand operand. + left: Box<Expr>, + /// The right-hand operand. + right: Box<Expr>, + }, + + /// Unary negation (e.g., `-x`). + /// + /// See `XPath` 1.0 section 3.5. + UnaryNeg(Box<Expr>), + + /// A function call (e.g., `contains(name, 'foo')`). + /// + /// See `XPath` 1.0 section 3.2. + FunctionCall { + /// The function name. + name: String, + /// The argument expressions. + args: Vec<Expr>, + }, + + /// A relative location path (e.g., `child::p/child::a`). + /// + /// See `XPath` 1.0 section 2. + Path { + /// The steps in the path, evaluated left to right. + steps: Vec<Step>, + }, + + /// An absolute location path (e.g., `/html/body`). + /// + /// An empty `steps` vector represents the bare `/` (root node). + /// + /// See `XPath` 1.0 section 2. + RootPath { + /// The steps following the initial `/`. + steps: Vec<Step>, + }, + + /// A filter expression with predicates (e.g., `$nodes[1]`). + /// + /// See `XPath` 1.0 section 3.3. + Filter { + /// The primary expression being filtered. + expr: Box<Expr>, + /// The predicate expressions. + predicates: Vec<Expr>, + }, + + /// A filter expression followed by a relative location path + /// (e.g., `(//a)[1]/@href` or `$nodes//b`). + /// + /// See `XPath` 1.0 section 3.3 (`PathExpr`). + FilterPath { + /// The filter expression producing the initial node-set. + expr: Box<Expr>, + /// The location path steps applied to that node-set. + steps: Vec<Step>, + }, + + /// A union of two node-sets (e.g., `a | b`). + /// + /// See `XPath` 1.0 section 3.3. + Union(Box<Expr>, Box<Expr>), +} + +/// A binary operator in an `XPath` expression. +/// +/// Covers arithmetic, comparison, and logical operators. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BinaryOp { + /// Addition (`+`). + Add, + /// Subtraction (`-`). + Sub, + /// Multiplication (`*`). + Mul, + /// Division (`div`). + Div, + /// Modulo (`mod`). + Mod, + /// Equality (`=`). + Eq, + /// Inequality (`!=`). + Neq, + /// Less than (`<`). + Lt, + /// Less than or equal (`<=`). + Lte, + /// Greater than (`>`). + Gt, + /// Greater than or equal (`>=`). + Gte, + /// Logical and (`and`). + And, + /// Logical or (`or`). + Or, +} + +/// A single step in a location path. +/// +/// A step consists of an axis, a node test, and zero or more predicates. +/// For example, in `child::p[@class='intro']`, the axis is `Child`, +/// the node test is `Name("p")`, and there is one predicate. +/// +/// See `XPath` 1.0 section 2.1. +#[derive(Debug, Clone)] +pub struct Step { + /// The axis along which to select nodes. + pub axis: Axis, + /// The test applied to each candidate node. + pub node_test: NodeTest, + /// Predicate expressions that further filter the selected nodes. + pub predicates: Vec<Expr>, +} + +/// An `XPath` axis, specifying the direction of node selection. +/// +/// `XPath` 1.0 defines 13 axes. See `XPath` 1.0 section 2.2. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Axis { + /// The `child` axis: direct children. + Child, + /// The `descendant` axis: all descendants (children, grandchildren, etc.). + Descendant, + /// The `parent` axis: the immediate parent. + Parent, + /// The `ancestor` axis: all ancestors up to and including the root. + Ancestor, + /// The `following-sibling` axis: siblings that come after this node. + FollowingSibling, + /// The `preceding-sibling` axis: siblings that come before this node. + PrecedingSibling, + /// The `following` axis: all nodes after this node in document order. + Following, + /// The `preceding` axis: all nodes before this node in document order. + Preceding, + /// The `attribute` axis: attributes of the context node. + Attribute, + /// The `namespace` axis: namespace nodes of the context node. + Namespace, + /// The `self` axis: just the context node itself. + Self_, + /// The `descendant-or-self` axis: the context node and its descendants. + DescendantOrSelf, + /// The `ancestor-or-self` axis: the context node and its ancestors. + AncestorOrSelf, +} + +impl Axis { + /// Returns the axis name as it appears in `XPath` syntax. + /// + /// # Examples + /// + /// ```ignore + /// assert_eq!(Axis::Child.as_str(), "child"); + /// assert_eq!(Axis::DescendantOrSelf.as_str(), "descendant-or-self"); + /// ``` + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Child => "child", + Self::Descendant => "descendant", + Self::Parent => "parent", + Self::Ancestor => "ancestor", + Self::FollowingSibling => "following-sibling", + Self::PrecedingSibling => "preceding-sibling", + Self::Following => "following", + Self::Preceding => "preceding", + Self::Attribute => "attribute", + Self::Namespace => "namespace", + Self::Self_ => "self", + Self::DescendantOrSelf => "descendant-or-self", + Self::AncestorOrSelf => "ancestor-or-self", + } + } + + /// Parses an axis name string into an `Axis` variant. + /// + /// Returns `None` if the string is not a recognized axis name. + #[must_use] + pub fn parse(s: &str) -> Option<Self> { + match s { + "child" => Some(Self::Child), + "descendant" => Some(Self::Descendant), + "parent" => Some(Self::Parent), + "ancestor" => Some(Self::Ancestor), + "following-sibling" => Some(Self::FollowingSibling), + "preceding-sibling" => Some(Self::PrecedingSibling), + "following" => Some(Self::Following), + "preceding" => Some(Self::Preceding), + "attribute" => Some(Self::Attribute), + "namespace" => Some(Self::Namespace), + "self" => Some(Self::Self_), + "descendant-or-self" => Some(Self::DescendantOrSelf), + "ancestor-or-self" => Some(Self::AncestorOrSelf), + _ => None, + } + } +} + +impl std::fmt::Display for Axis { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A node test in a location path step. +/// +/// Node tests filter candidate nodes by name or kind. +/// +/// See `XPath` 1.0 section 2.3. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NodeTest { + /// A name test matching a specific element or attribute name. + /// + /// The string may be an `NCName` or a `QName` (with prefix). + Name(String), + + /// The `*` wildcard, matching any name. + Wildcard, + + /// A prefixed wildcard like `prefix:*`, matching any local name in + /// the namespace bound to the given prefix. + PrefixWildcard(String), + + /// The `node()` node type test, matching any node. + Node, + + /// The `text()` node type test, matching text nodes. + Text, + + /// The `comment()` node type test, matching comment nodes. + Comment, + + /// The `processing-instruction()` node type test. + /// + /// When the optional string is `Some`, it matches only PIs with that target + /// name (e.g., `processing-instruction('xml-stylesheet')`). + ProcessingInstruction(Option<String>), +} + +impl std::fmt::Display for NodeTest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Name(name) => write!(f, "{name}"), + Self::Wildcard => f.write_str("*"), + Self::PrefixWildcard(prefix) => write!(f, "{prefix}:*"), + Self::Node => f.write_str("node()"), + Self::Text => f.write_str("text()"), + Self::Comment => f.write_str("comment()"), + Self::ProcessingInstruction(None) => f.write_str("processing-instruction()"), + Self::ProcessingInstruction(Some(name)) => { + write!(f, "processing-instruction('{name}')") + } + } + } +} + +impl std::fmt::Display for BinaryOp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Add => f.write_str("+"), + Self::Sub => f.write_str("-"), + Self::Mul => f.write_str("*"), + Self::Div => f.write_str("div"), + Self::Mod => f.write_str("mod"), + Self::Eq => f.write_str("="), + Self::Neq => f.write_str("!="), + Self::Lt => f.write_str("<"), + Self::Lte => f.write_str("<="), + Self::Gt => f.write_str(">"), + Self::Gte => f.write_str(">="), + Self::And => f.write_str("and"), + Self::Or => f.write_str("or"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_axis_roundtrip() { + let axes = [ + Axis::Child, + Axis::Descendant, + Axis::Parent, + Axis::Ancestor, + Axis::FollowingSibling, + Axis::PrecedingSibling, + Axis::Following, + Axis::Preceding, + Axis::Attribute, + Axis::Namespace, + Axis::Self_, + Axis::DescendantOrSelf, + Axis::AncestorOrSelf, + ]; + for axis in axes { + let name = axis.as_str(); + let parsed = Axis::parse(name); + assert_eq!(parsed, Some(axis), "roundtrip failed for {name}"); + } + } + + #[test] + fn test_axis_from_str_invalid() { + assert_eq!(Axis::parse("invalid"), None); + assert_eq!(Axis::parse(""), None); + assert_eq!(Axis::parse("children"), None); + } + + #[test] + fn test_axis_display() { + assert_eq!(Axis::Child.to_string(), "child"); + assert_eq!(Axis::DescendantOrSelf.to_string(), "descendant-or-self"); + assert_eq!(Axis::AncestorOrSelf.to_string(), "ancestor-or-self"); + assert_eq!(Axis::FollowingSibling.to_string(), "following-sibling"); + } + + #[test] + fn test_node_test_display() { + assert_eq!(NodeTest::Name("foo".to_string()).to_string(), "foo"); + assert_eq!(NodeTest::Wildcard.to_string(), "*"); + assert_eq!( + NodeTest::PrefixWildcard("svg".to_string()).to_string(), + "svg:*" + ); + assert_eq!(NodeTest::Node.to_string(), "node()"); + assert_eq!(NodeTest::Text.to_string(), "text()"); + assert_eq!(NodeTest::Comment.to_string(), "comment()"); + assert_eq!( + NodeTest::ProcessingInstruction(None).to_string(), + "processing-instruction()" + ); + assert_eq!( + NodeTest::ProcessingInstruction(Some("xml-stylesheet".to_string())).to_string(), + "processing-instruction('xml-stylesheet')" + ); + } + + #[test] + fn test_binary_op_display() { + assert_eq!(BinaryOp::Add.to_string(), "+"); + assert_eq!(BinaryOp::Sub.to_string(), "-"); + assert_eq!(BinaryOp::Mul.to_string(), "*"); + assert_eq!(BinaryOp::Div.to_string(), "div"); + assert_eq!(BinaryOp::Mod.to_string(), "mod"); + assert_eq!(BinaryOp::Eq.to_string(), "="); + assert_eq!(BinaryOp::Neq.to_string(), "!="); + assert_eq!(BinaryOp::Lt.to_string(), "<"); + assert_eq!(BinaryOp::Lte.to_string(), "<="); + assert_eq!(BinaryOp::Gt.to_string(), ">"); + assert_eq!(BinaryOp::Gte.to_string(), ">="); + assert_eq!(BinaryOp::And.to_string(), "and"); + assert_eq!(BinaryOp::Or.to_string(), "or"); + } + + #[test] + fn test_expr_clone() { + let expr = Expr::BinaryOp { + op: BinaryOp::Add, + left: Box::new(Expr::Number(1.0)), + right: Box::new(Expr::Number(2.0)), + }; + let cloned = expr.clone(); + match cloned { + Expr::BinaryOp { op, .. } => assert_eq!(op, BinaryOp::Add), + _ => panic!("unexpected variant after clone"), + } + } + + #[test] + fn test_step_construction() { + let step = Step { + axis: Axis::Child, + node_test: NodeTest::Name("p".to_string()), + predicates: vec![Expr::Number(1.0)], + }; + assert_eq!(step.axis, Axis::Child); + assert_eq!(step.node_test, NodeTest::Name("p".to_string())); + assert_eq!(step.predicates.len(), 1); + } +} diff --git a/browser/vendor/xmloxide/src/xpath/eval.rs b/browser/vendor/xmloxide/src/xpath/eval.rs new file mode 100644 index 000000000..1bec3236a --- /dev/null +++ b/browser/vendor/xmloxide/src/xpath/eval.rs @@ -0,0 +1,3149 @@ +//! `XPath` 1.0 expression evaluator. +//! +//! This module implements the core evaluation engine for `XPath` 1.0 expressions +//! as specified in <https://www.w3.org/TR/xpath-10/>. It walks an [`Expr`] AST +//! (produced by [`super::parser::parse`]) and evaluates it against a +//! [`Document`] tree, producing an [`XPathValue`]. +//! +//! # Evaluation Context +//! +//! Per `XPath` 1.0 section 1, every expression is evaluated with respect to a +//! **context** consisting of a context node, context position, context size, +//! variable bindings, and a function library. The [`XPathContext`] struct holds +//! all of these. +//! +//! # Location Paths +//! +//! The evaluator supports all 13 `XPath` axes, node test filtering, and +//! predicate evaluation. Results are maintained in document order. +//! +//! # Functions +//! +//! All 27 core `XPath` 1.0 functions are implemented (node-set, string, +//! boolean, and number function groups). + +use std::collections::{HashMap, HashSet}; + +use super::ast::{Axis, BinaryOp, Expr, NodeTest, Step}; +use super::types::{XPathError, XPathNode}; +use crate::tree::{Attribute, Document, NodeId, NodeKind}; + +/// Evaluation context for an `XPath` 1.0 expression. +/// +/// Holds the document reference, the context node, context position and size, +/// and variable bindings. Created via [`XPathContext::new`] and then used to +/// evaluate parsed `XPath` expressions via [`XPathContext::evaluate`]. +/// +/// # Examples +/// +/// ```ignore +/// use xmloxide::xpath::eval::XPathContext; +/// use xmloxide::xpath::parser::parse; +/// use xmloxide::Document; +/// +/// let doc = Document::parse_str("<root><a/><b/></root>").unwrap(); +/// let root = doc.root_element().unwrap(); +/// let expr = parse("count(*)").unwrap(); +/// let ctx = XPathContext::new(&doc, root); +/// let result = ctx.evaluate(&expr).unwrap(); +/// ``` +pub struct XPathContext<'a> { + /// The document being queried. + doc: &'a Document, + /// The context node for expression evaluation (may be an attribute node + /// when evaluating predicates over attribute-axis results). + context_node: XPathNode, + /// 1-based position of the context node within its context node-set. + context_position: usize, + /// The size of the context node-set. + context_size: usize, + /// Variable bindings available during evaluation. + variables: HashMap<String, XPathValue>, + /// Namespace prefix → URI bindings for resolving prefixed name tests. + /// + /// When set (e.g., via Schematron `<sch:ns>` bindings), prefixed names + /// like `inv:invoice` in `XPath` expressions are matched by resolving + /// the prefix to a URI and comparing against the element's namespace URI + /// and local name. + namespaces: HashMap<String, String>, +} + +/// An `XPath` 1.0 value. +/// +/// Re-exported locally to keep the evaluator self-contained. This mirrors +/// [`super::types::XPathValue`] but uses `String` payloads computed within +/// the evaluator for node-set string-value access. +pub use super::types::XPathValue; + +impl<'a> XPathContext<'a> { + /// Creates a new evaluation context rooted at `context_node`. + /// + /// The context position and size are both set to 1 (as if the context + /// node is the only member of a singleton node-set). + #[must_use] + pub fn new(doc: &'a Document, context_node: NodeId) -> Self { + Self::new_at(doc, XPathNode::Node(context_node)) + } + + /// Creates a new evaluation context at an arbitrary `XPath` node, + /// including an attribute node. + /// + /// Used when the context node comes from a previous `XPath` evaluation + /// (e.g., a Schematron rule whose context expression selects + /// attributes). + #[must_use] + pub fn new_at(doc: &'a Document, context_node: XPathNode) -> Self { + Self { + doc, + context_node, + context_position: 1, + context_size: 1, + variables: HashMap::new(), + namespaces: HashMap::new(), + } + } + + /// Looks up an attribute node's underlying [`Attribute`] data. + fn attr(&self, owner: NodeId, index: u32) -> Option<&Attribute> { + self.doc.attributes(owner).get(index as usize) + } + + /// Registers a namespace prefix → URI binding for name resolution. + /// + /// When evaluating `XPath` expressions containing prefixed name tests + /// (e.g., `//inv:invoice`), the prefix is resolved to a URI using these + /// bindings, and the element's namespace URI and local name are compared + /// instead of the raw `QName` string. + /// + /// This is used by Schematron validation to pass `<sch:ns>` bindings + /// to the `XPath` evaluator. + pub fn set_namespace(&mut self, prefix: &str, uri: &str) { + self.namespaces.insert(prefix.to_owned(), uri.to_owned()); + } + + /// Binds a variable name to a value in this context. + /// + /// Variable references in expressions (e.g., `$x`) will resolve to the + /// value set here. + pub fn set_variable(&mut self, name: &str, value: XPathValue) { + self.variables.insert(name.to_owned(), value); + } + + /// Evaluates an `XPath` expression AST against this context. + /// + /// # Errors + /// + /// Returns [`XPathError`] if evaluation fails (e.g., undefined variable, + /// unknown function, type mismatch). + pub fn evaluate(&self, expr: &Expr) -> Result<XPathValue, XPathError> { + self.eval_expr(expr) + } + + // ----------------------------------------------------------------------- + // Internal expression dispatch + // ----------------------------------------------------------------------- + + fn eval_expr(&self, expr: &Expr) -> Result<XPathValue, XPathError> { + match expr { + Expr::Number(n) => Ok(XPathValue::Number(*n)), + Expr::String(s) => Ok(XPathValue::String(s.clone())), + Expr::Variable(name) => self.eval_variable(name), + Expr::BinaryOp { op, left, right } => self.eval_binary_op(*op, left, right), + Expr::UnaryNeg(inner) => self.eval_unary_neg(inner), + Expr::FunctionCall { name, args } => self.eval_function(name, args), + Expr::Path { steps } => self.eval_relative_path(steps), + Expr::RootPath { steps } => self.eval_root_path(steps), + Expr::Filter { expr, predicates } => self.eval_filter(expr, predicates), + Expr::FilterPath { expr, steps } => self.eval_filter_path(expr, steps), + Expr::Union(left, right) => self.eval_union(left, right), + } + } + + // ----------------------------------------------------------------------- + // Variable lookup + // ----------------------------------------------------------------------- + + fn eval_variable(&self, name: &str) -> Result<XPathValue, XPathError> { + self.variables + .get(name) + .cloned() + .ok_or_else(|| XPathError::UndefinedVariable { + name: name.to_owned(), + }) + } + + // ----------------------------------------------------------------------- + // Binary operations + // ----------------------------------------------------------------------- + + fn eval_binary_op( + &self, + op: BinaryOp, + left: &Expr, + right: &Expr, + ) -> Result<XPathValue, XPathError> { + match op { + BinaryOp::And => { + let lv = self.eval_expr(left)?; + if !self.value_to_boolean(&lv) { + return Ok(XPathValue::Boolean(false)); + } + let rv = self.eval_expr(right)?; + Ok(XPathValue::Boolean(self.value_to_boolean(&rv))) + } + BinaryOp::Or => { + let lv = self.eval_expr(left)?; + if self.value_to_boolean(&lv) { + return Ok(XPathValue::Boolean(true)); + } + let rv = self.eval_expr(right)?; + Ok(XPathValue::Boolean(self.value_to_boolean(&rv))) + } + BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => { + let lv = self.eval_expr(left)?; + let rv = self.eval_expr(right)?; + let ln = self.value_to_number(&lv); + let rn = self.value_to_number(&rv); + let result = match op { + BinaryOp::Add => ln + rn, + BinaryOp::Sub => ln - rn, + BinaryOp::Mul => ln * rn, + BinaryOp::Div => ln / rn, + BinaryOp::Mod => ln % rn, + _ => unreachable!(), + }; + Ok(XPathValue::Number(result)) + } + BinaryOp::Eq | BinaryOp::Neq => { + let lv = self.eval_expr(left)?; + let rv = self.eval_expr(right)?; + Ok(XPathValue::Boolean(self.compare_equality(op, &lv, &rv))) + } + BinaryOp::Lt | BinaryOp::Lte | BinaryOp::Gt | BinaryOp::Gte => { + let lv = self.eval_expr(left)?; + let rv = self.eval_expr(right)?; + let result = self.compare_relational(op, &lv, &rv); + Ok(XPathValue::Boolean(result)) + } + } + } + + // ----------------------------------------------------------------------- + // Unary negation + // ----------------------------------------------------------------------- + + fn eval_unary_neg(&self, inner: &Expr) -> Result<XPathValue, XPathError> { + let val = self.eval_expr(inner)?; + Ok(XPathValue::Number(-self.value_to_number(&val))) + } + + // ----------------------------------------------------------------------- + // Location paths + // ----------------------------------------------------------------------- + + fn eval_relative_path(&self, steps: &[Step]) -> Result<XPathValue, XPathError> { + self.eval_steps_from(vec![self.context_node], steps) + } + + /// Applies location path `steps` to an initial node-set. + /// + /// Shared by relative paths, absolute paths, and filter-path expressions + /// (e.g., `(//a)[1]/@href`). Handles the + /// `descendant-or-self::node()/child::X` fusion. + fn eval_steps_from( + &self, + mut nodes: Vec<XPathNode>, + steps: &[Step], + ) -> Result<XPathValue, XPathError> { + let mut i = 0; + while i < steps.len() { + let step = &steps[i]; + // Optimization: fuse descendant-or-self::node()/child::X into + // descendant::X — avoids materializing the huge intermediate + // node-set that `//` produces. + if let Some(fused) = Self::try_fuse_descendant_child(steps, i) { + nodes = self.apply_step(&nodes, &fused)?; + i += 2; // skip both steps + } else { + nodes = self.apply_step(&nodes, step)?; + i += 1; + } + } + Ok(XPathValue::NodeSet(nodes)) + } + + /// Evaluates a filter expression followed by a relative location path, + /// e.g., `(//a)[1]/@href`: the filter yields a node-set, and the steps + /// are applied to it like any other location path continuation. + fn eval_filter_path(&self, expr: &Expr, steps: &[Step]) -> Result<XPathValue, XPathError> { + let val = self.eval_expr(expr)?; + let nodes = match val { + XPathValue::NodeSet(ns) => ns, + other => { + return Err(XPathError::TypeError { + expected: "node-set".to_owned(), + found: other.type_name().to_owned(), + }); + } + }; + self.eval_steps_from(nodes, steps) + } + + /// Tries to fuse `descendant-or-self::node()` + `child::X` at position `i` + /// into a single `descendant::X` step when the child step has no + /// predicates. Predicates must retain their per-parent proximity context, + /// so fusing `//*[1]` would be observably wrong. + fn try_fuse_descendant_child(steps: &[Step], i: usize) -> Option<Step> { + if i + 1 >= steps.len() { + return None; + } + let first = &steps[i]; + let second = &steps[i + 1]; + // Pattern: descendant-or-self::node() with no predicates, + // followed by child::X (with or without predicates). + if first.axis == Axis::DescendantOrSelf + && first.node_test == NodeTest::Node + && first.predicates.is_empty() + && second.axis == Axis::Child + && second.predicates.is_empty() + { + Some(Step { + axis: Axis::Descendant, + node_test: second.node_test.clone(), + predicates: second.predicates.clone(), + }) + } else { + None + } + } + + fn eval_root_path(&self, steps: &[Step]) -> Result<XPathValue, XPathError> { + self.eval_steps_from(vec![XPathNode::Node(self.doc.root())], steps) + } + + /// Applies a single step to every node in `input`, producing a new node + /// set in document order with duplicates removed. + fn apply_step(&self, input: &[XPathNode], step: &Step) -> Result<Vec<XPathNode>, XPathError> { + let mut result: Vec<XPathNode> = Vec::new(); + + if step.predicates.is_empty() { + if input.len() == 1 { + // Fast path: single input node — no dedup needed. + self.expand_axis_filtered(input[0], step.axis, &step.node_test, &mut result); + } else { + // Multiple input nodes — need dedup via HashSet. + let mut seen = HashSet::new(); + for &node in input { + self.expand_axis_filtered_dedup( + node, + step.axis, + &step.node_test, + &mut result, + &mut seen, + ); + } + } + } else { + // Predicates filter the node-set generated for EACH context node + // separately, with proximity positions relative to that + // per-context set (XPath 1.0 §2.4/§3.3): //a/b[1] selects the + // first b of every a. The per-context results are then merged + // with duplicates removed. + let mut seen: HashSet<XPathNode> = HashSet::new(); + for &node in input { + let mut per_node = Vec::new(); + self.expand_axis_filtered(node, step.axis, &step.node_test, &mut per_node); + for pred in &step.predicates { + per_node = self.apply_predicate(&per_node, pred)?; + } + if input.len() == 1 { + result = per_node; + break; + } + for n in per_node { + if seen.insert(n) { + result.push(n); + } + } + } + } + + // Each context node contributes a separately evaluated axis set. Their + // concatenation is not necessarily document ordered (notably the + // child step after `//`), while libxml returns node-sets in document + // order. Reverse-axis proximity was already applied above. + sort_document_order(&mut result); + + Ok(result) + } + + // ----------------------------------------------------------------------- + // Fused axis expansion + node test filtering + // ----------------------------------------------------------------------- + + /// Expands axis from `node`, filters by `test`, and pushes matching nodes + /// directly into `result`. No intermediate Vec allocation. + #[allow(clippy::too_many_lines)] + fn expand_axis_filtered( + &self, + node: XPathNode, + axis: Axis, + test: &NodeTest, + result: &mut Vec<XPathNode>, + ) { + let node = match node { + XPathNode::Attribute { owner, index } => { + self.expand_axis_from_attribute(owner, index, axis, test, result); + return; + } + XPathNode::Node(id) => id, + }; + if axis == Axis::Attribute { + self.push_matching_attributes(node, test, result); + return; + } + if axis == Axis::Namespace { + result.extend( + self.apply_namespace_node_test(node, test) + .into_iter() + .map(XPathNode::Node), + ); + return; + } + + // Fast path for Name test on element-scanning axes: inline the name + // check to avoid per-node function call overhead through + // node_matches_test. This is the hottest path for queries like + // //entry/title or /root/child. + // + // Only use the fast path when no namespace bindings are registered, + // because namespace-aware matching needs the full matches_element_name + // logic. + if let NodeTest::Name(name) = test { + if self.namespaces.is_empty() { + match axis { + Axis::Child => { + for child in self.doc.children(node) { + if let NodeKind::Element { + name: elem_name, .. + } = &self.doc.node(child).kind + { + if elem_name == name { + result.push(XPathNode::Node(child)); + } + } + } + return; + } + Axis::Descendant => { + for desc in self.doc.descendants(node) { + if let NodeKind::Element { + name: elem_name, .. + } = &self.doc.node(desc).kind + { + if elem_name == name { + result.push(XPathNode::Node(desc)); + } + } + } + return; + } + Axis::DescendantOrSelf => { + if let NodeKind::Element { + name: elem_name, .. + } = &self.doc.node(node).kind + { + if elem_name == name { + result.push(XPathNode::Node(node)); + } + } + for desc in self.doc.descendants(node) { + if let NodeKind::Element { + name: elem_name, .. + } = &self.doc.node(desc).kind + { + if elem_name == name { + result.push(XPathNode::Node(desc)); + } + } + } + return; + } + _ => {} // fall through to generic path + } + } + } + + match axis { + Axis::Child => { + for child in self.doc.children(node) { + if self.node_matches_test(child, test, axis) { + result.push(XPathNode::Node(child)); + } + } + } + Axis::Descendant => { + for desc in self.doc.descendants(node) { + if self.node_matches_test(desc, test, axis) { + result.push(XPathNode::Node(desc)); + } + } + } + Axis::DescendantOrSelf => { + if self.node_matches_test(node, test, axis) { + result.push(XPathNode::Node(node)); + } + for desc in self.doc.descendants(node) { + if self.node_matches_test(desc, test, axis) { + result.push(XPathNode::Node(desc)); + } + } + } + Axis::Self_ => { + if self.node_matches_test(node, test, axis) { + result.push(XPathNode::Node(node)); + } + } + Axis::Parent => { + if let Some(p) = self.doc.parent(node) { + if self.node_matches_test(p, test, axis) { + result.push(XPathNode::Node(p)); + } + } + } + _ => { + // For less common axes, fall back to expand + filter + let axis_nodes = self.expand_axis(node, axis); + for id in axis_nodes { + if self.node_matches_test(id, test, axis) { + result.push(XPathNode::Node(id)); + } + } + } + } + } + + /// Like `expand_axis_filtered` but with deduplication via `seen` `HashSet`. + fn expand_axis_filtered_dedup( + &self, + node: XPathNode, + axis: Axis, + test: &NodeTest, + result: &mut Vec<XPathNode>, + seen: &mut HashSet<XPathNode>, + ) { + // Expand into a scratch vec, then dedup-push. Axis expansion from a + // single node never yields duplicates, so dedup is only needed + // across input nodes. + let mut scratch = Vec::new(); + self.expand_axis_filtered(node, axis, test, &mut scratch); + for n in scratch { + if seen.insert(n) { + result.push(n); + } + } + } + + /// Expands an axis from an attribute node (`XPath` 1.0 section 2.2): + /// the owner element is the attribute's parent, and attributes have no + /// children and are not siblings of any node. + fn expand_axis_from_attribute( + &self, + owner: NodeId, + index: u32, + axis: Axis, + test: &NodeTest, + result: &mut Vec<XPathNode>, + ) { + match axis { + Axis::Parent if self.node_matches_test(owner, test, axis) => { + result.push(XPathNode::Node(owner)); + } + Axis::Ancestor => { + let mut current = Some(owner); + while let Some(n) = current { + if self.node_matches_test(n, test, axis) { + result.push(XPathNode::Node(n)); + } + current = self.doc.parent(n); + } + } + Axis::Self_ | Axis::AncestorOrSelf | Axis::DescendantOrSelf => { + // The principal node type of these axes is element, so only + // node() matches the attribute itself (XPath 1.0 §2.3). + if *test == NodeTest::Node { + result.push(XPathNode::Attribute { owner, index }); + } + if axis == Axis::AncestorOrSelf { + let mut current = Some(owner); + while let Some(n) = current { + if self.node_matches_test(n, test, axis) { + result.push(XPathNode::Node(n)); + } + current = self.doc.parent(n); + } + } + } + Axis::Following => { + // libxml anchors an attribute's following axis after its + // owner element, excluding that owner's descendants. + for id in self.following_nodes(owner) { + if self.node_matches_test(id, test, axis) { + result.push(XPathNode::Node(id)); + } + } + } + Axis::Preceding => { + for id in self.preceding_nodes(owner) { + if self.node_matches_test(id, test, axis) { + result.push(XPathNode::Node(id)); + } + } + } + // Child, Descendant, Attribute, Namespace, FollowingSibling, + // PrecedingSibling: empty for attribute nodes. + _ => {} + } + } + + /// Expands the attribute axis of an element: one entry per matching + /// attribute, in attribute-list order. + /// + /// Namespace declarations (`xmlns`, `xmlns:*`) are not attribute nodes + /// in the `XPath` 1.0 data model (section 5.3) and are skipped. + fn push_matching_attributes( + &self, + element: NodeId, + test: &NodeTest, + result: &mut Vec<XPathNode>, + ) { + for (i, attr) in self.doc.attributes(element).iter().enumerate() { + if attr.prefix.as_deref() == Some("xmlns") + || (attr.prefix.is_none() && attr.name == "xmlns") + { + continue; + } + let matches = match test { + NodeTest::Name(name) => self.attr_name_matches(attr, name), + NodeTest::Wildcard | NodeTest::Node => true, + NodeTest::PrefixWildcard(prefix) => { + if let Some(uri) = self.namespaces.get(prefix.as_str()) { + attr.namespace.as_deref() == Some(uri.as_str()) + } else { + attr.prefix.as_deref() == Some(prefix.as_str()) + } + } + _ => false, + }; + if matches { + #[allow(clippy::cast_possible_truncation)] + result.push(XPathNode::Attribute { + owner: element, + index: i as u32, + }); + } + } + } + + /// Tests an attribute against a name test. + /// + /// A `prefix:local` test matches namespace-aware when the prefix is + /// registered via [`set_namespace`](Self::set_namespace), and by literal + /// prefix otherwise. An unprefixed test matches on the local name + /// (matching the evaluator's historical behavior for element names). + fn attr_name_matches(&self, attr: &Attribute, name: &str) -> bool { + if let Some(colon) = name.find(':') { + let prefix = &name[..colon]; + let local = &name[colon + 1..]; + if let Some(uri) = self.namespaces.get(prefix) { + return attr.namespace.as_deref() == Some(uri.as_str()) && attr.name == local; + } + return attr.prefix.as_deref() == Some(prefix) && attr.name == local; + } + attr.name == name + } + + // ----------------------------------------------------------------------- + // Axis expansion (for uncommon axes) + // ----------------------------------------------------------------------- + + /// Returns all candidate nodes along the given axis from `node`. + fn expand_axis(&self, node: NodeId, axis: Axis) -> Vec<NodeId> { + match axis { + Axis::Child => self.doc.children(node).collect(), + Axis::Descendant => self.doc.descendants(node).collect(), + Axis::Parent => self.doc.parent(node).into_iter().collect(), + Axis::Ancestor => { + let mut result = Vec::new(); + let mut current = self.doc.parent(node); + while let Some(p) = current { + result.push(p); + current = self.doc.parent(p); + } + result + } + Axis::FollowingSibling => { + let mut result = Vec::new(); + let mut current = self.doc.next_sibling(node); + while let Some(s) = current { + result.push(s); + current = self.doc.next_sibling(s); + } + result + } + Axis::PrecedingSibling => { + let mut result = Vec::new(); + let mut current = self.doc.prev_sibling(node); + while let Some(s) = current { + result.push(s); + current = self.doc.prev_sibling(s); + } + result + } + Axis::Following => self.following_nodes(node), + Axis::Preceding => self.preceding_nodes(node), + Axis::Attribute => { + // Attributes are not tree nodes in our arena. We return an + // empty vec here; attribute axis handling is done via + // `apply_node_test` which inspects the element's attributes + // directly. + Vec::new() + } + Axis::Namespace => { + // Namespace axis handled via apply_namespace_node_test + // in the fused paths. Fallback here for completeness. + Vec::new() + } + Axis::Self_ => vec![node], + Axis::DescendantOrSelf => { + let mut result = vec![node]; + result.extend(self.doc.descendants(node)); + result + } + Axis::AncestorOrSelf => { + let mut result = vec![node]; + let mut current = self.doc.parent(node); + while let Some(p) = current { + result.push(p); + current = self.doc.parent(p); + } + result + } + } + } + + /// Returns all nodes after `node` in document order, excluding descendants. + fn following_nodes(&self, node: NodeId) -> Vec<NodeId> { + let mut result = Vec::new(); + // First, try following siblings and their subtrees + let mut current = self.doc.next_sibling(node); + while let Some(s) = current { + result.push(s); + result.extend(self.doc.descendants(s)); + current = self.doc.next_sibling(s); + } + // Then walk up ancestors and collect their following siblings + let mut ancestor = self.doc.parent(node); + while let Some(anc) = ancestor { + let mut sib = self.doc.next_sibling(anc); + while let Some(s) = sib { + result.push(s); + result.extend(self.doc.descendants(s)); + sib = self.doc.next_sibling(s); + } + ancestor = self.doc.parent(anc); + } + result + } + + /// Returns all nodes before `node` in document order, excluding ancestors. + fn preceding_nodes(&self, node: NodeId) -> Vec<NodeId> { + let mut result = Vec::new(); + // Preceding siblings and their subtrees (in reverse document order) + let mut current = self.doc.prev_sibling(node); + while let Some(s) = current { + // Add descendants first (they come after `s` in document order) + let descs: Vec<NodeId> = self.doc.descendants(s).collect(); + for &d in descs.iter().rev() { + result.push(d); + } + result.push(s); + current = self.doc.prev_sibling(s); + } + // Walk up ancestors and collect their preceding siblings + let mut ancestor = self.doc.parent(node); + while let Some(anc) = ancestor { + let mut sib = self.doc.prev_sibling(anc); + while let Some(s) = sib { + let descs: Vec<NodeId> = self.doc.descendants(s).collect(); + for &d in descs.iter().rev() { + result.push(d); + } + result.push(s); + sib = self.doc.prev_sibling(s); + } + ancestor = self.doc.parent(anc); + } + result + } + + // ----------------------------------------------------------------------- + // Node test filtering + // ----------------------------------------------------------------------- + + /// Tests whether a name test matches an element, accounting for namespace + /// prefix bindings when available. + /// + /// When the name contains a colon and namespace bindings are registered, + /// splits the name into prefix + local, resolves the prefix to a URI, + /// and compares against the element's namespace URI and local name. + /// Otherwise, falls back to direct string comparison. + #[inline] + fn matches_element_name(&self, name: &str, elem: &NodeKind) -> bool { + if let NodeKind::Element { + name: elem_name, + namespace, + .. + } = elem + { + // If the XPath name has a prefix and we have namespace bindings, + // do namespace-aware matching. + if let Some(colon_pos) = name.find(':') { + if !self.namespaces.is_empty() { + let prefix = &name[..colon_pos]; + let local = &name[colon_pos + 1..]; + if let Some(uri) = self.namespaces.get(prefix) { + return namespace.as_deref() == Some(uri.as_str()) && elem_name == local; + } + } + } + // Default: direct string comparison (matches existing behavior) + elem_name == name + } else { + false + } + } + + /// Checks whether a single node matches a node test for a given axis. + #[inline] + fn node_matches_test(&self, id: NodeId, test: &NodeTest, axis: Axis) -> bool { + let node = self.doc.node(id); + match test { + NodeTest::Name(name) => match &node.kind { + kind @ NodeKind::Element { .. } => self.matches_element_name(name, kind), + NodeKind::ProcessingInstruction { target, .. } + if axis == Axis::Child + || axis == Axis::Descendant + || axis == Axis::DescendantOrSelf => + { + target == name + } + _ => false, + }, + NodeTest::Wildcard => { + // Wildcard matches any element on most axes; on the attribute + // axis it matches any attribute (handled separately). + matches!(node.kind, NodeKind::Element { .. }) + } + NodeTest::PrefixWildcard(prefix) => match &node.kind { + NodeKind::Element { + namespace, + prefix: elem_prefix, + .. + } => { + // If we have namespace bindings, resolve the prefix to a URI + // and match against the element's namespace. + if let Some(uri) = self.namespaces.get(prefix.as_str()) { + namespace.as_deref() == Some(uri.as_str()) + } else { + elem_prefix.as_deref() == Some(prefix.as_str()) + } + } + _ => false, + }, + NodeTest::Node => true, + NodeTest::Text => matches!(node.kind, NodeKind::Text { .. } | NodeKind::CData { .. }), + NodeTest::Comment => matches!(node.kind, NodeKind::Comment { .. }), + NodeTest::ProcessingInstruction(opt_name) => match &node.kind { + NodeKind::ProcessingInstruction { target, .. } => opt_name + .as_ref() + .map_or(true, |expected| target == expected), + _ => false, + }, + } + } + + /// Handles the namespace axis: returns the element's `NodeId` if the + /// element has any in-scope namespace bindings that match `test`. + /// + /// Namespace nodes in `XPath` 1.0 (section 5.4) represent namespace + /// bindings in scope on an element. Each element has one namespace node + /// for every namespace prefix in scope, plus the implicit `xml` prefix. + /// + /// Since namespace nodes are not tree nodes in our arena, we follow the + /// same pattern as the attribute axis: return the element's own `NodeId` + /// when a match is found. + fn apply_namespace_node_test(&self, element: NodeId, test: &NodeTest) -> Vec<NodeId> { + if !matches!(self.doc.node(element).kind, NodeKind::Element { .. }) { + return Vec::new(); + } + + // Collect in-scope namespace prefixes by walking from the element up + // to the root, respecting the closest declaration for each prefix. + let mut ns_map: Vec<(Option<&str>, &str)> = Vec::new(); + let mut seen_prefixes: HashSet<Option<&str>> = HashSet::new(); + + let mut current = Some(element); + while let Some(id) = current { + for attr in self.doc.attributes(id) { + if attr.name == "xmlns" && attr.prefix.is_none() { + // Default namespace declaration: xmlns="..." + if seen_prefixes.insert(None) && !attr.value.is_empty() { + ns_map.push((None, &attr.value)); + } + } else if attr.prefix.as_deref() == Some("xmlns") { + // Prefixed namespace declaration: xmlns:prefix="..." + // The local name (attr.name) is the namespace prefix. + if seen_prefixes.insert(Some(attr.name.as_str())) { + ns_map.push((Some(attr.name.as_str()), &attr.value)); + } + } + } + current = self.doc.parent(id); + } + + // The xml prefix is always implicitly in scope. + if seen_prefixes.insert(Some("xml")) { + ns_map.push((Some("xml"), "http://www.w3.org/XML/1998/namespace")); + } + + match test { + NodeTest::Name(name) => { + // Match a specific namespace prefix + let target: Option<&str> = if name.is_empty() { + None + } else { + Some(name.as_str()) + }; + if ns_map.iter().any(|(prefix, _)| *prefix == target) { + vec![element] + } else { + Vec::new() + } + } + NodeTest::Wildcard | NodeTest::Node => { + // Return the element once if any namespace bindings exist. + if ns_map.is_empty() { + Vec::new() + } else { + vec![element] + } + } + _ => Vec::new(), + } + } + + // ----------------------------------------------------------------------- + // Predicate evaluation + // ----------------------------------------------------------------------- + + /// Applies a predicate to a node set, filtering down to nodes where the + /// predicate is true. + /// + /// Per `XPath` 1.0 section 2.4, if the predicate evaluates to a number, + /// it is compared to the context position (positional predicate). Otherwise + /// it is converted to boolean. + fn apply_predicate( + &self, + nodes: &[XPathNode], + predicate: &Expr, + ) -> Result<Vec<XPathNode>, XPathError> { + let size = nodes.len(); + let mut result = Vec::new(); + + // Fast path for positional predicates like [1], [last()], etc. + // If the predicate is a numeric literal, we can skip evaluating + // it for every node. + if let Expr::Number(n) = predicate { + // Convert float position to 1-based integer index. + // XPath positions are 1-based, so [1] means the first node. + let pos = *n; + if pos >= 1.0 && pos.fract() == 0.0 { + // Safe: we checked pos is a non-negative integer that fits in usize range. + // Node sets can never exceed u32::MAX nodes (arena limit), so no precision loss. + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let idx = pos as usize - 1; + if let Some(&node) = nodes.get(idx) { + result.push(node); + } + } + return Ok(result); + } + + for (i, &node) in nodes.iter().enumerate() { + let ctx = XPathContext { + doc: self.doc, + context_node: node, + context_position: i + 1, // 1-based + context_size: size, + // Share the variables reference — avoid cloning the HashMap. + // HashMap::new() doesn't allocate, so this is free when empty. + variables: if self.variables.is_empty() { + HashMap::new() + } else { + self.variables.clone() + }, + namespaces: self.namespaces.clone(), + }; + let val = ctx.eval_expr(predicate)?; + let keep = match &val { + XPathValue::Number(n) => { + // Positional predicate: [1] means first node + #[allow(clippy::float_cmp, clippy::cast_precision_loss)] + let pos_match = *n == (i + 1) as f64; + pos_match + } + _ => self.value_to_boolean(&val), + }; + if keep { + result.push(node); + } + } + + Ok(result) + } + + // ----------------------------------------------------------------------- + // Filter expressions + // ----------------------------------------------------------------------- + + fn eval_filter(&self, expr: &Expr, predicates: &[Expr]) -> Result<XPathValue, XPathError> { + let val = self.eval_expr(expr)?; + let mut nodes = match val { + XPathValue::NodeSet(ns) => ns, + other => { + return Err(XPathError::TypeError { + expected: "node-set".to_owned(), + found: other.type_name().to_owned(), + }); + } + }; + + for pred in predicates { + nodes = self.apply_predicate(&nodes, pred)?; + } + + Ok(XPathValue::NodeSet(nodes)) + } + + // ----------------------------------------------------------------------- + // Union + // ----------------------------------------------------------------------- + + fn eval_union(&self, left: &Expr, right: &Expr) -> Result<XPathValue, XPathError> { + let lv = self.eval_expr(left)?; + let rv = self.eval_expr(right)?; + + let mut lnodes = match lv { + XPathValue::NodeSet(ns) => ns, + other => { + return Err(XPathError::TypeError { + expected: "node-set".to_owned(), + found: other.type_name().to_owned(), + }); + } + }; + let rnodes = match rv { + XPathValue::NodeSet(ns) => ns, + other => { + return Err(XPathError::TypeError { + expected: "node-set".to_owned(), + found: other.type_name().to_owned(), + }); + } + }; + + // Merge, dedup, sort + let seen: HashSet<XPathNode> = lnodes.iter().copied().collect(); + for id in rnodes { + if !seen.contains(&id) { + lnodes.push(id); + } + } + sort_document_order(&mut lnodes); + + Ok(XPathValue::NodeSet(lnodes)) + } + + // ----------------------------------------------------------------------- + // Function dispatch + // ----------------------------------------------------------------------- + + fn eval_function(&self, name: &str, args: &[Expr]) -> Result<XPathValue, XPathError> { + match name { + // Node-set functions + "last" => self.fn_last(args), + "position" => self.fn_position(args), + "count" => self.fn_count(args), + "local-name" => self.fn_local_name(args), + "namespace-uri" => self.fn_namespace_uri(args), + "name" => self.fn_name(args), + + // String functions + "string" => self.fn_string(args), + "concat" => self.fn_concat(args), + "starts-with" => self.fn_starts_with(args), + "contains" => self.fn_contains(args), + "substring-before" => self.fn_substring_before(args), + "substring-after" => self.fn_substring_after(args), + "substring" => self.fn_substring(args), + "string-length" => self.fn_string_length(args), + "normalize-space" => self.fn_normalize_space(args), + "translate" => self.fn_translate(args), + + // Boolean functions + "boolean" => self.fn_boolean(args), + "not" => self.fn_not(args), + "true" => self.fn_true(args), + "false" => self.fn_false(args), + "lang" => self.fn_lang(args), + + // Number functions + "number" => self.fn_number(args), + "sum" => self.fn_sum(args), + "floor" => self.fn_floor(args), + "ceiling" => self.fn_ceiling(args), + "round" => self.fn_round(args), + + // id() - stub + "id" => self.fn_id(args), + + // XPath 2.0 / XSD functions commonly used in Schematron + "matches" => self.fn_matches(args), + "replace" => self.fn_replace(args), + "tokenize" => self.fn_tokenize(args), + + // XPath 2.0 string functions + "upper-case" => self.fn_upper_case(args), + "lower-case" => self.fn_lower_case(args), + "ends-with" => self.fn_ends_with(args), + "string-join" => self.fn_string_join(args), + + // XPath 2.0 sequence/numeric functions + "empty" => self.fn_empty(args), + "exists" => self.fn_exists(args), + "abs" => self.fn_abs(args), + "min" => self.fn_min(args), + "max" => self.fn_max(args), + "reverse" => self.fn_reverse(args), + + _ => Err(XPathError::UndefinedFunction { + name: name.to_owned(), + }), + } + } + + // -- Node-set functions ------------------------------------------------- + + #[allow(clippy::cast_precision_loss)] + fn fn_last(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("last", args, 0)?; + Ok(XPathValue::Number(self.context_size as f64)) + } + + #[allow(clippy::cast_precision_loss)] + fn fn_position(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("position", args, 0)?; + Ok(XPathValue::Number(self.context_position as f64)) + } + + #[allow(clippy::cast_precision_loss)] + fn fn_count(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("count", args, 1)?; + let val = self.eval_expr(&args[0])?; + match &val { + XPathValue::NodeSet(ns) => Ok(XPathValue::Number(ns.len() as f64)), + other => Err(XPathError::TypeError { + expected: "node-set".to_owned(), + found: other.type_name().to_owned(), + }), + } + } + + fn fn_local_name(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + if args.len() > 1 { + return Err(XPathError::InvalidArgCount { + function: "local-name".to_owned(), + expected: 1, + found: args.len(), + }); + } + let node = if args.is_empty() { + self.context_node + } else { + let val = self.eval_expr(&args[0])?; + match &val { + XPathValue::NodeSet(ns) if !ns.is_empty() => ns[0], + XPathValue::NodeSet(_) => return Ok(XPathValue::String(String::new())), + other => { + return Err(XPathError::TypeError { + expected: "node-set".to_owned(), + found: other.type_name().to_owned(), + }); + } + } + }; + if let XPathNode::Attribute { owner, index } = node { + let local = self.attr(owner, index).map(|a| a.name.clone()); + return Ok(XPathValue::String(local.unwrap_or_default())); + } + let name = self.doc.node_name(node.anchor()).unwrap_or(""); + // Strip prefix if present (local-name returns the part after ':') + let local = name.split(':').next_back().unwrap_or(name); + Ok(XPathValue::String(local.to_owned())) + } + + fn fn_namespace_uri(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + if args.len() > 1 { + return Err(XPathError::InvalidArgCount { + function: "namespace-uri".to_owned(), + expected: 1, + found: args.len(), + }); + } + let node = if args.is_empty() { + self.context_node + } else { + let val = self.eval_expr(&args[0])?; + match &val { + XPathValue::NodeSet(ns) if !ns.is_empty() => ns[0], + XPathValue::NodeSet(_) => return Ok(XPathValue::String(String::new())), + other => { + return Err(XPathError::TypeError { + expected: "node-set".to_owned(), + found: other.type_name().to_owned(), + }); + } + } + }; + if let XPathNode::Attribute { owner, index } = node { + let uri = self.attr(owner, index).and_then(|a| a.namespace.clone()); + return Ok(XPathValue::String(uri.unwrap_or_default())); + } + let uri = self.doc.node_namespace(node.anchor()).unwrap_or(""); + Ok(XPathValue::String(uri.to_owned())) + } + + fn fn_name(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + if args.len() > 1 { + return Err(XPathError::InvalidArgCount { + function: "name".to_owned(), + expected: 1, + found: args.len(), + }); + } + let node = if args.is_empty() { + self.context_node + } else { + let val = self.eval_expr(&args[0])?; + match &val { + XPathValue::NodeSet(ns) if !ns.is_empty() => ns[0], + XPathValue::NodeSet(_) => return Ok(XPathValue::String(String::new())), + other => { + return Err(XPathError::TypeError { + expected: "node-set".to_owned(), + found: other.type_name().to_owned(), + }); + } + } + }; + if let XPathNode::Attribute { owner, index } = node { + let qname = self.attr(owner, index).map(|a| match &a.prefix { + Some(prefix) => format!("{prefix}:{}", a.name), + None => a.name.clone(), + }); + return Ok(XPathValue::String(qname.unwrap_or_default())); + } + let name = self.doc.node_name(node.anchor()).unwrap_or(""); + Ok(XPathValue::String(name.to_owned())) + } + + // -- String functions --------------------------------------------------- + + fn fn_string(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + if args.len() > 1 { + return Err(XPathError::InvalidArgCount { + function: "string".to_owned(), + expected: 1, + found: args.len(), + }); + } + if args.is_empty() { + let sv = self.string_value(self.context_node); + return Ok(XPathValue::String(sv)); + } + let val = self.eval_expr(&args[0])?; + Ok(XPathValue::String(self.value_to_string(&val))) + } + + fn fn_concat(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + if args.len() < 2 { + return Err(XPathError::InvalidArgCount { + function: "concat".to_owned(), + expected: 2, + found: args.len(), + }); + } + let mut result = String::new(); + for arg in args { + let val = self.eval_expr(arg)?; + result.push_str(&self.value_to_string(&val)); + } + Ok(XPathValue::String(result)) + } + + fn fn_starts_with(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("starts-with", args, 2)?; + let s = self.value_to_string(&self.eval_expr(&args[0])?); + let prefix = self.value_to_string(&self.eval_expr(&args[1])?); + Ok(XPathValue::Boolean(s.starts_with(prefix.as_str()))) + } + + fn fn_contains(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("contains", args, 2)?; + let s = self.value_to_string(&self.eval_expr(&args[0])?); + let sub = self.value_to_string(&self.eval_expr(&args[1])?); + Ok(XPathValue::Boolean(s.contains(sub.as_str()))) + } + + fn fn_substring_before(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("substring-before", args, 2)?; + let s = self.value_to_string(&self.eval_expr(&args[0])?); + let sub = self.value_to_string(&self.eval_expr(&args[1])?); + let result = s + .find(sub.as_str()) + .map_or_else(String::new, |pos| s[..pos].to_owned()); + Ok(XPathValue::String(result)) + } + + fn fn_substring_after(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("substring-after", args, 2)?; + let s = self.value_to_string(&self.eval_expr(&args[0])?); + let sub = self.value_to_string(&self.eval_expr(&args[1])?); + let result = s + .find(sub.as_str()) + .map_or_else(String::new, |pos| s[(pos + sub.len())..].to_owned()); + Ok(XPathValue::String(result)) + } + + /// `XPath` `substring(string, number, number?)` per section 4.2. + /// + /// Uses `XPath` rounding: `round()` each numeric argument, 1-based indexing. + /// The substring starts at position `round(arg2)` and has length + /// `round(arg3)` if provided. + fn fn_substring(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + if args.len() < 2 || args.len() > 3 { + return Err(XPathError::InvalidArgCount { + function: "substring".to_owned(), + expected: 2, + found: args.len(), + }); + } + let s = self.value_to_string(&self.eval_expr(&args[0])?); + let pos = self.value_to_number(&self.eval_expr(&args[1])?); + let len = if args.len() == 3 { + Some(self.value_to_number(&self.eval_expr(&args[2])?)) + } else { + None + }; + + // XPath substring is 1-based with special rounding rules + let rounded_pos = xpath_round(pos); + let chars: Vec<char> = s.chars().collect(); + #[allow(clippy::cast_precision_loss)] + let str_len = chars.len() as f64; + + // Compute start and end indices (1-based, may be NaN/Inf) + let start = rounded_pos; + let end = len.map_or(str_len + 1.0, |l| rounded_pos + xpath_round(l)); + + // Handle NaN: if start or end is NaN, return empty string + if start.is_nan() || end.is_nan() { + return Ok(XPathValue::String(String::new())); + } + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let actual_start = (start - 1.0).max(0.0) as usize; + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let actual_end = (end - 1.0).min(str_len).max(0.0) as usize; + + if actual_start >= actual_end || actual_start >= chars.len() { + return Ok(XPathValue::String(String::new())); + } + + let result: String = chars[actual_start..actual_end].iter().collect(); + Ok(XPathValue::String(result)) + } + + fn fn_string_length(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + if args.len() > 1 { + return Err(XPathError::InvalidArgCount { + function: "string-length".to_owned(), + expected: 1, + found: args.len(), + }); + } + let s = if args.is_empty() { + self.string_value(self.context_node) + } else { + self.value_to_string(&self.eval_expr(&args[0])?) + }; + #[allow(clippy::cast_precision_loss)] + let len = s.chars().count() as f64; + Ok(XPathValue::Number(len)) + } + + /// `normalize-space(string?)` per `XPath` 1.0 section 4.2. + /// + /// Strips leading and trailing whitespace and collapses sequences of + /// whitespace characters to a single space. + fn fn_normalize_space(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + if args.len() > 1 { + return Err(XPathError::InvalidArgCount { + function: "normalize-space".to_owned(), + expected: 1, + found: args.len(), + }); + } + let s = if args.is_empty() { + self.string_value(self.context_node) + } else { + self.value_to_string(&self.eval_expr(&args[0])?) + }; + let normalized: String = s.split_whitespace().collect::<Vec<_>>().join(" "); + Ok(XPathValue::String(normalized)) + } + + /// `translate(string, string, string)` per `XPath` 1.0 section 4.2. + /// + /// Replaces characters in the first string that appear in the second string + /// with the corresponding character in the third string. Characters with no + /// corresponding replacement are removed. + fn fn_translate(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("translate", args, 3)?; + let s = self.value_to_string(&self.eval_expr(&args[0])?); + let from = self.value_to_string(&self.eval_expr(&args[1])?); + let to = self.value_to_string(&self.eval_expr(&args[2])?); + + let from_chars: Vec<char> = from.chars().collect(); + let to_chars: Vec<char> = to.chars().collect(); + + let result: String = s + .chars() + .filter_map(|c| { + if let Some(pos) = from_chars.iter().position(|&fc| fc == c) { + to_chars.get(pos).copied() + } else { + Some(c) + } + }) + .collect(); + Ok(XPathValue::String(result)) + } + + // -- Boolean functions -------------------------------------------------- + + fn fn_boolean(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("boolean", args, 1)?; + let val = self.eval_expr(&args[0])?; + Ok(XPathValue::Boolean(self.value_to_boolean(&val))) + } + + fn fn_not(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("not", args, 1)?; + let val = self.eval_expr(&args[0])?; + Ok(XPathValue::Boolean(!self.value_to_boolean(&val))) + } + + #[allow(clippy::unused_self)] + fn fn_true(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("true", args, 0)?; + Ok(XPathValue::Boolean(true)) + } + + #[allow(clippy::unused_self)] + fn fn_false(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("false", args, 0)?; + Ok(XPathValue::Boolean(false)) + } + + fn fn_lang(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("lang", args, 1)?; + let target = self.value_to_string(&self.eval_expr(&args[0])?); + let target_lower = target.to_lowercase(); + + // Walk ancestors looking for xml:lang attribute + let mut node = Some(self.context_node.anchor()); + while let Some(n) = node { + if let Some(lang) = self.doc.attribute(n, "xml:lang") { + let lang_lower = lang.to_lowercase(); + if lang_lower == target_lower || lang_lower.starts_with(&format!("{target_lower}-")) + { + return Ok(XPathValue::Boolean(true)); + } + return Ok(XPathValue::Boolean(false)); + } + node = self.doc.parent(n); + } + Ok(XPathValue::Boolean(false)) + } + + // -- Number functions --------------------------------------------------- + + fn fn_number(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + if args.len() > 1 { + return Err(XPathError::InvalidArgCount { + function: "number".to_owned(), + expected: 1, + found: args.len(), + }); + } + if args.is_empty() { + let sv = self.string_value(self.context_node); + return Ok(XPathValue::Number(parse_xpath_number(&sv))); + } + let val = self.eval_expr(&args[0])?; + Ok(XPathValue::Number(self.value_to_number(&val))) + } + + fn fn_sum(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("sum", args, 1)?; + let val = self.eval_expr(&args[0])?; + match &val { + XPathValue::NodeSet(ns) => { + let total: f64 = ns + .iter() + .map(|&n| parse_xpath_number(&self.string_value(n))) + .sum(); + Ok(XPathValue::Number(total)) + } + other => Err(XPathError::TypeError { + expected: "node-set".to_owned(), + found: other.type_name().to_owned(), + }), + } + } + + fn fn_floor(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("floor", args, 1)?; + let n = self.value_to_number(&self.eval_expr(&args[0])?); + Ok(XPathValue::Number(n.floor())) + } + + fn fn_ceiling(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("ceiling", args, 1)?; + let n = self.value_to_number(&self.eval_expr(&args[0])?); + Ok(XPathValue::Number(n.ceil())) + } + + /// `round(number)` per `XPath` 1.0 section 4.4. + /// + /// Rounds to the nearest integer, with halfway cases going to positive + /// infinity (e.g., `round(0.5)` = 1, `round(-0.5)` = 0). + fn fn_round(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("round", args, 1)?; + let n = self.value_to_number(&self.eval_expr(&args[0])?); + Ok(XPathValue::Number(xpath_round(n))) + } + + /// `id(object)` — selects elements by their ID attribute value. + /// + /// When the argument is a node-set, the string-value of each node is used + /// as a whitespace-separated list of IDs. When the argument is any other + /// type, it is converted to a string and treated as a whitespace-separated + /// list of IDs. Returns the elements whose ID matches, in document order. + /// + /// See `XPath` 1.0 section 4.1. + fn fn_id(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("id", args, 1)?; + let val = self.eval_expr(&args[0])?; + let mut result = Vec::new(); + let mut seen = HashSet::new(); + + let id_strings: Vec<String> = match &val { + XPathValue::NodeSet(nodes) => nodes.iter().map(|&n| self.string_value(n)).collect(), + other => vec![self.value_to_string(other)], + }; + + for id_str in &id_strings { + for token in id_str.split_whitespace() { + if let Some(node) = self.doc.element_by_id(token) { + if seen.insert(node) { + result.push(XPathNode::Node(node)); + } + } + } + } + + sort_document_order(&mut result); + Ok(XPathValue::NodeSet(result)) + } + + // -- XPath 2.0 functions (commonly used in Schematron) ------------------ + + /// `matches(string, pattern)` or `matches(string, pattern, flags)`. + /// + /// Returns true if the string matches the regular expression pattern. + /// See `XPath` 2.0 Functions and Operators section 7.6.2. + fn fn_matches(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + if args.len() < 2 || args.len() > 3 { + return Err(XPathError::InvalidArgCount { + function: "matches".to_owned(), + expected: 2, + found: args.len(), + }); + } + let input = self.value_to_string(&self.eval_expr(&args[0])?); + let pattern = self.value_to_string(&self.eval_expr(&args[1])?); + let flags = if args.len() == 3 { + self.value_to_string(&self.eval_expr(&args[2])?) + } else { + String::new() + }; + match super::regex::xpath_matches(&input, &pattern, &flags) { + Ok(result) => Ok(XPathValue::Boolean(result)), + Err(e) => Err(XPathError::InternalError { + message: format!("regex error in matches(): {e}"), + }), + } + } + + /// `replace(string, pattern, replacement)` or with flags. + /// + /// Replaces all matches of `pattern` in `string` with `replacement`. + /// See `XPath` 2.0 F&O section 7.6.3. + fn fn_replace(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + if args.len() < 3 || args.len() > 4 { + return Err(XPathError::InvalidArgCount { + function: "replace".to_owned(), + expected: 3, + found: args.len(), + }); + } + let input = self.value_to_string(&self.eval_expr(&args[0])?); + let pattern = self.value_to_string(&self.eval_expr(&args[1])?); + let replacement = self.value_to_string(&self.eval_expr(&args[2])?); + let flags = if args.len() == 4 { + self.value_to_string(&self.eval_expr(&args[3])?) + } else { + String::new() + }; + match super::regex::xpath_replace(&input, &pattern, &replacement, &flags) { + Ok(result) => Ok(XPathValue::String(result)), + Err(e) => Err(XPathError::InternalError { + message: format!("regex error in replace(): {e}"), + }), + } + } + + /// `tokenize(string, pattern)` or with flags. + /// + /// Splits `string` on occurrences of `pattern`. + /// See `XPath` 2.0 F&O section 7.6.4. + fn fn_tokenize(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + if args.is_empty() || args.len() > 3 { + return Err(XPathError::InvalidArgCount { + function: "tokenize".to_owned(), + expected: 2, + found: args.len(), + }); + } + let input = self.value_to_string(&self.eval_expr(&args[0])?); + let pattern = if args.len() >= 2 { + self.value_to_string(&self.eval_expr(&args[1])?) + } else { + r"\s+".to_string() + }; + let flags = if args.len() == 3 { + self.value_to_string(&self.eval_expr(&args[2])?) + } else { + String::new() + }; + match super::regex::xpath_tokenize(&input, &pattern, &flags) { + Ok(tokens) => { + // Return as a NodeSet-like structure. Since tokenize returns + // strings not nodes, return as a String (joining with space) + // for XPath 1.0 compatibility. In a full XPath 2.0 impl this + // would return a sequence of strings. + Ok(XPathValue::String(tokens.join(" "))) + } + Err(e) => Err(XPathError::InternalError { + message: format!("regex error in tokenize(): {e}"), + }), + } + } + + // -- XPath 2.0 string functions ------------------------------------------- + + /// `upper-case(string)` — converts to uppercase. + fn fn_upper_case(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("upper-case", args, 1)?; + let s = self.value_to_string(&self.eval_expr(&args[0])?); + Ok(XPathValue::String(s.to_uppercase())) + } + + /// `lower-case(string)` — converts to lowercase. + fn fn_lower_case(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("lower-case", args, 1)?; + let s = self.value_to_string(&self.eval_expr(&args[0])?); + Ok(XPathValue::String(s.to_lowercase())) + } + + /// `ends-with(string, suffix)` — tests if string ends with suffix. + fn fn_ends_with(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("ends-with", args, 2)?; + let s = self.value_to_string(&self.eval_expr(&args[0])?); + let suffix = self.value_to_string(&self.eval_expr(&args[1])?); + Ok(XPathValue::Boolean(s.ends_with(&suffix))) + } + + /// `string-join(sequence, separator)` — joins strings with a separator. + /// + /// In our `XPath` 1.0-based model, operates on a node-set by joining + /// each node's string value. + fn fn_string_join(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + if args.is_empty() || args.len() > 2 { + return Err(XPathError::InvalidArgCount { + function: "string-join".to_owned(), + expected: 2, + found: args.len(), + }); + } + let val = self.eval_expr(&args[0])?; + let sep = if args.len() == 2 { + self.value_to_string(&self.eval_expr(&args[1])?) + } else { + String::new() + }; + match &val { + XPathValue::NodeSet(nodes) => { + let strings: Vec<String> = nodes.iter().map(|&n| self.string_value(n)).collect(); + Ok(XPathValue::String(strings.join(&sep))) + } + XPathValue::String(s) => Ok(XPathValue::String(s.clone())), + other => Ok(XPathValue::String(other.to_xpath_string())), + } + } + + // -- XPath 2.0 sequence/boolean functions --------------------------------- + + /// `empty(sequence)` — returns true if the node-set is empty. + fn fn_empty(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("empty", args, 1)?; + let val = self.eval_expr(&args[0])?; + match &val { + XPathValue::NodeSet(ns) => Ok(XPathValue::Boolean(ns.is_empty())), + XPathValue::String(s) => Ok(XPathValue::Boolean(s.is_empty())), + _ => Ok(XPathValue::Boolean(false)), + } + } + + /// `exists(sequence)` — returns true if the node-set is non-empty. + fn fn_exists(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("exists", args, 1)?; + let val = self.eval_expr(&args[0])?; + match &val { + XPathValue::NodeSet(ns) => Ok(XPathValue::Boolean(!ns.is_empty())), + XPathValue::String(s) => Ok(XPathValue::Boolean(!s.is_empty())), + _ => Ok(XPathValue::Boolean(true)), + } + } + + /// `abs(number)` — absolute value. + fn fn_abs(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("abs", args, 1)?; + let n = self.value_to_number(&self.eval_expr(&args[0])?); + Ok(XPathValue::Number(n.abs())) + } + + /// `min(node-set)` — minimum numeric value of node string values. + fn fn_min(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("min", args, 1)?; + let val = self.eval_expr(&args[0])?; + match &val { + XPathValue::NodeSet(nodes) if nodes.is_empty() => Ok(XPathValue::Number(f64::NAN)), + XPathValue::NodeSet(nodes) => { + let mut min = f64::INFINITY; + for &n in nodes { + let v = parse_xpath_number(&self.string_value(n)); + if v.is_nan() { + return Ok(XPathValue::Number(f64::NAN)); + } + if v < min { + min = v; + } + } + Ok(XPathValue::Number(min)) + } + other => Ok(XPathValue::Number(self.value_to_number(other))), + } + } + + /// `max(node-set)` — maximum numeric value of node string values. + fn fn_max(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("max", args, 1)?; + let val = self.eval_expr(&args[0])?; + match &val { + XPathValue::NodeSet(nodes) if nodes.is_empty() => Ok(XPathValue::Number(f64::NAN)), + XPathValue::NodeSet(nodes) => { + let mut max = f64::NEG_INFINITY; + for &n in nodes { + let v = parse_xpath_number(&self.string_value(n)); + if v.is_nan() { + return Ok(XPathValue::Number(f64::NAN)); + } + if v > max { + max = v; + } + } + Ok(XPathValue::Number(max)) + } + other => Ok(XPathValue::Number(self.value_to_number(other))), + } + } + + /// `reverse(node-set)` — returns the node-set in reverse document order. + fn fn_reverse(&self, args: &[Expr]) -> Result<XPathValue, XPathError> { + check_arg_count("reverse", args, 1)?; + let val = self.eval_expr(&args[0])?; + match val { + XPathValue::NodeSet(mut nodes) => { + nodes.reverse(); + Ok(XPathValue::NodeSet(nodes)) + } + other => Ok(other), + } + } + + // ----------------------------------------------------------------------- + // Type conversion helpers (with document access) + // ----------------------------------------------------------------------- + + /// Converts a value to boolean per `XPath` 1.0 section 4.3. + #[allow(clippy::unused_self)] + fn value_to_boolean(&self, val: &XPathValue) -> bool { + val.to_boolean() + } + + /// Converts a value to number per `XPath` 1.0 section 4.4. + /// + /// For node-sets, computes the string-value of the first node first. + fn value_to_number(&self, val: &XPathValue) -> f64 { + match val { + XPathValue::NodeSet(ns) => { + if ns.is_empty() { + f64::NAN + } else { + let sv = self.string_value(ns[0]); + parse_xpath_number(&sv) + } + } + _ => val.to_number(), + } + } + + /// Converts a value to string per `XPath` 1.0 section 4.2. + /// + /// For node-sets, computes the string-value of the first node in document + /// order. + fn value_to_string(&self, val: &XPathValue) -> String { + match val { + XPathValue::NodeSet(ns) => { + if ns.is_empty() { + String::new() + } else { + self.string_value(ns[0]) + } + } + _ => val.to_xpath_string(), + } + } + + /// Computes the string-value of a node per `XPath` 1.0 section 5. + /// + /// - Root / Element: concatenation of all descendant text nodes + /// - Attribute: the attribute value + /// - Text / CDATA: the text content + /// - Comment: the comment text + /// - PI: the PI data + fn string_value(&self, node: XPathNode) -> String { + let node = match node { + XPathNode::Attribute { owner, index } => { + return self + .attr(owner, index) + .map(|a| a.value.clone()) + .unwrap_or_default(); + } + XPathNode::Node(id) => id, + }; + let kind = &self.doc.node(node).kind; + match kind { + // EntityRef: the expansion lives in the node's children, so the + // string-value is the same concatenation of descendant text. + NodeKind::Document | NodeKind::Element { .. } | NodeKind::EntityRef { .. } => { + self.doc.text_content(node) + } + NodeKind::Text { content } + | NodeKind::CData { content } + | NodeKind::Comment { content } => content.clone(), + NodeKind::ProcessingInstruction { data, .. } => { + data.as_deref().unwrap_or("").to_owned() + } + NodeKind::DocumentType { .. } => String::new(), + } + } + + // ----------------------------------------------------------------------- + // Comparison helpers + // ----------------------------------------------------------------------- + + /// Compares two values with `=` or `!=` per `XPath` 1.0 section 3.4. + /// + /// When either operand is a node-set (and the other is not a boolean), + /// both operators are existential: the result is true iff *some* node + /// satisfies the predicate. `!=` is therefore **not** the negation of + /// `=` — an empty node-set compares false under both operators, and a + /// multi-node set can satisfy both at once. A node-set compared against + /// a boolean is converted to a boolean first, and scalar operands are + /// compared directly. + #[allow(clippy::float_cmp)] + fn compare_equality(&self, op: BinaryOp, lhs: &XPathValue, rhs: &XPathValue) -> bool { + let ne = op == BinaryOp::Neq; + let str_cmp = |a: &str, b: &str| (a == b) != ne; + let num_cmp = |a: f64, b: f64| (a == b) != ne; + let bool_cmp = |a: bool, b: bool| (a == b) != ne; + match (lhs, rhs) { + // node-set = node-set + (XPathValue::NodeSet(lns), XPathValue::NodeSet(rns)) => { + for &ln in lns { + let lsv = self.string_value(ln); + for &rn in rns { + let rsv = self.string_value(rn); + if str_cmp(&lsv, &rsv) { + return true; + } + } + } + false + } + // node-set = boolean + (XPathValue::NodeSet(ns), XPathValue::Boolean(b)) + | (XPathValue::Boolean(b), XPathValue::NodeSet(ns)) => bool_cmp(!ns.is_empty(), *b), + // node-set = number + (XPathValue::NodeSet(ns), XPathValue::Number(n)) + | (XPathValue::Number(n), XPathValue::NodeSet(ns)) => ns + .iter() + .any(|&node| num_cmp(parse_xpath_number(&self.string_value(node)), *n)), + // node-set = string + (XPathValue::NodeSet(ns), XPathValue::String(s)) + | (XPathValue::String(s), XPathValue::NodeSet(ns)) => { + ns.iter().any(|&node| str_cmp(&self.string_value(node), s)) + } + // Both booleans + (XPathValue::Boolean(a), XPathValue::Boolean(b)) => bool_cmp(*a, *b), + // If either is boolean, convert both to boolean + (XPathValue::Boolean(_), _) | (_, XPathValue::Boolean(_)) => { + bool_cmp(lhs.to_boolean(), rhs.to_boolean()) + } + // If either is number, convert both to number + (XPathValue::Number(a), XPathValue::Number(b)) => num_cmp(*a, *b), + (XPathValue::Number(_), _) | (_, XPathValue::Number(_)) => { + num_cmp(self.value_to_number(lhs), self.value_to_number(rhs)) + } + // Otherwise compare as strings + _ => str_cmp(&self.value_to_string(lhs), &self.value_to_string(rhs)), + } + } + + /// Compares two values relationally per `XPath` 1.0 section 3.4. + fn compare_relational(&self, op: BinaryOp, lhs: &XPathValue, rhs: &XPathValue) -> bool { + let cmp = |a: f64, b: f64| -> bool { + match op { + BinaryOp::Lt => a < b, + BinaryOp::Lte => a <= b, + BinaryOp::Gt => a > b, + BinaryOp::Gte => a >= b, + _ => false, + } + }; + + match (lhs, rhs) { + // node-set <op> node-set + (XPathValue::NodeSet(lns), XPathValue::NodeSet(rns)) => { + for &ln in lns { + let lv = parse_xpath_number(&self.string_value(ln)); + for &rn in rns { + let rv = parse_xpath_number(&self.string_value(rn)); + if cmp(lv, rv) { + return true; + } + } + } + false + } + // node-set <op> number + (XPathValue::NodeSet(ns), XPathValue::Number(n)) => ns + .iter() + .any(|&node| cmp(parse_xpath_number(&self.string_value(node)), *n)), + (XPathValue::Number(n), XPathValue::NodeSet(ns)) => ns + .iter() + .any(|&node| cmp(*n, parse_xpath_number(&self.string_value(node)))), + // node-set <op> string + (XPathValue::NodeSet(ns), XPathValue::String(s)) => { + let rn = parse_xpath_number(s); + ns.iter() + .any(|&node| cmp(parse_xpath_number(&self.string_value(node)), rn)) + } + (XPathValue::String(s), XPathValue::NodeSet(ns)) => { + let ln = parse_xpath_number(s); + ns.iter() + .any(|&node| cmp(ln, parse_xpath_number(&self.string_value(node)))) + } + // node-set <op> boolean -- convert node-set to boolean, then to number + (XPathValue::NodeSet(ns), _) => { + let lv = if ns.is_empty() { 0.0 } else { 1.0 }; + let rv = self.value_to_number(rhs); + cmp(lv, rv) + } + (_, XPathValue::NodeSet(ns)) => { + let lv = self.value_to_number(lhs); + let rv = if ns.is_empty() { 0.0 } else { 1.0 }; + cmp(lv, rv) + } + // Otherwise compare as numbers + _ => cmp(self.value_to_number(lhs), self.value_to_number(rhs)), + } + } +} + +// --------------------------------------------------------------------------- +// Helper functions +// --------------------------------------------------------------------------- + +/// Sorts a node-set into document order using the arena index as a proxy. +/// +/// Since nodes are allocated in document order during parsing, the arena +/// index (encoded in `NodeId`) directly reflects document order; attribute +/// nodes sort just after their owner element, by attribute index. +fn sort_document_order(nodes: &mut [XPathNode]) { + nodes.sort_unstable(); +} + +/// Rounds a number using `XPath` rounding rules: round half toward positive +/// infinity. +/// +/// Per `XPath` 1.0 section 4.4: `round(-0.5)` = 0, `round(0.5)` = 1. +fn xpath_round(n: f64) -> f64 { + if n.is_nan() || n.is_infinite() { + return n; + } + // XPath round: round half toward positive infinity + // This is equivalent to floor(n + 0.5) + (n + 0.5).floor() +} + +/// Parses a string into an `XPath` number per section 4.4. +fn parse_xpath_number(s: &str) -> f64 { + let trimmed = s.trim(); + if trimmed.is_empty() { + return f64::NAN; + } + trimmed.parse::<f64>().unwrap_or(f64::NAN) +} + +/// Checks that a function was called with the expected number of arguments. +fn check_arg_count(name: &str, args: &[Expr], expected: usize) -> Result<(), XPathError> { + if args.len() != expected { + return Err(XPathError::InvalidArgCount { + function: name.to_owned(), + expected, + found: args.len(), + }); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::float_cmp, clippy::unwrap_used)] +mod tests { + use super::*; + use crate::xpath::parser::parse; + + /// Helper: parse XML, get root element, evaluate `XPath` expression. + fn eval_xpath(xml: &str, xpath: &str) -> XPathValue { + let doc = Document::parse_str(xml).unwrap(); + let root = doc.root_element().unwrap(); + let expr = parse(xpath).unwrap(); + let ctx = XPathContext::new(&doc, root); + ctx.evaluate(&expr).unwrap() + } + + /// Helper: evaluate `XPath` from the document root (not the root element). + fn eval_xpath_from_doc_root(xml: &str, xpath: &str) -> XPathValue { + let doc = Document::parse_str(xml).unwrap(); + let root = doc.root(); + let expr = parse(xpath).unwrap(); + let ctx = XPathContext::new(&doc, root); + ctx.evaluate(&expr).unwrap() + } + + /// Helper: evaluate and return the count of nodes in a node-set. + fn eval_count(xml: &str, xpath: &str) -> usize { + match eval_xpath(xml, xpath) { + XPathValue::NodeSet(ns) => ns.len(), + other => panic!("expected node-set, got {other:?}"), + } + } + + // -- Arithmetic --------------------------------------------------------- + + #[test] + fn test_arithmetic_add() { + let result = eval_xpath("<r/>", "1 + 2"); + assert_eq!(result, XPathValue::Number(3.0)); + } + + #[test] + fn test_arithmetic_multiply() { + let result = eval_xpath("<r/>", "3 * 4"); + assert_eq!(result, XPathValue::Number(12.0)); + } + + #[test] + fn test_arithmetic_div() { + let result = eval_xpath("<r/>", "10 div 3"); + match result { + XPathValue::Number(n) => assert!((n - 10.0 / 3.0).abs() < 1e-10), + _ => panic!("expected number"), + } + } + + #[test] + fn test_arithmetic_mod() { + let result = eval_xpath("<r/>", "10 mod 3"); + assert_eq!(result, XPathValue::Number(1.0)); + } + + #[test] + fn test_arithmetic_sub() { + let result = eval_xpath("<r/>", "5 - 3"); + assert_eq!(result, XPathValue::Number(2.0)); + } + + // -- Comparisons -------------------------------------------------------- + + #[test] + fn test_comparison_eq() { + let result = eval_xpath("<r/>", "1 = 1"); + assert_eq!(result, XPathValue::Boolean(true)); + + let result = eval_xpath("<r/>", "1 = 2"); + assert_eq!(result, XPathValue::Boolean(false)); + } + + #[test] + fn test_comparison_neq() { + let result = eval_xpath("<r/>", "'a' != 'b'"); + assert_eq!(result, XPathValue::Boolean(true)); + + let result = eval_xpath("<r/>", "'a' != 'a'"); + assert_eq!(result, XPathValue::Boolean(false)); + } + + // Regression tests for issue #44: with a node-set operand, `!=` is its + // own existential test (XPath 1.0 §3.4), not the negation of `=`. + const NEQ_DOC: &str = + "<r><a x='1'>one</a><a x='2'>two</a><g><b>p</b><b>q</b></g><h><b>p</b><b>p</b></h></r>"; + + #[test] + fn test_neq_empty_nodeset_is_false() { + // An empty node-set satisfies neither `=` nor `!=`. + assert_eq!( + eval_xpath(NEQ_DOC, "//nothere != 'x'"), + XPathValue::Boolean(false) + ); + assert_eq!( + eval_xpath(NEQ_DOC, "//nothere != 1"), + XPathValue::Boolean(false) + ); + assert_eq!( + eval_xpath(NEQ_DOC, "//nothere != //a"), + XPathValue::Boolean(false) + ); + assert_eq!( + eval_xpath(NEQ_DOC, "//nothere != //nothere"), + XPathValue::Boolean(false) + ); + assert_eq!( + eval_xpath(NEQ_DOC, "//nothere = 'x'"), + XPathValue::Boolean(false) + ); + } + + #[test] + fn test_neq_nodeset_string_existential() { + // The node "one" makes `//a != 'two'` true, while the node "two" + // makes `//a = 'two'` true as well: both hold simultaneously. + assert_eq!( + eval_xpath(NEQ_DOC, "//a != 'two'"), + XPathValue::Boolean(true) + ); + assert_eq!( + eval_xpath(NEQ_DOC, "//a = 'two'"), + XPathValue::Boolean(true) + ); + assert_eq!( + eval_xpath(NEQ_DOC, "//a = 'two' and //a != 'two'"), + XPathValue::Boolean(true) + ); + // Single-node sets behave like scalars. + assert_eq!( + eval_xpath(NEQ_DOC, "//g/b[1] != 'p'"), + XPathValue::Boolean(false) + ); + assert_eq!( + eval_xpath(NEQ_DOC, "//g/b[2] != 'p'"), + XPathValue::Boolean(true) + ); + } + + #[test] + fn test_neq_attribute_nodeset_number() { + assert_eq!( + eval_xpath(NEQ_DOC, "//a/@x != 1"), + XPathValue::Boolean(true) + ); + assert_eq!(eval_xpath(NEQ_DOC, "//a/@x = 1"), XPathValue::Boolean(true)); + } + + #[test] + fn test_neq_absent_attribute_is_false() { + // An attribute step matching nothing is an EMPTY node-set, so both + // `=` and `!=` are false — not a String("") sentinel that would + // make `!=` true through the scalar branch. + assert_eq!( + eval_xpath(NEQ_DOC, "//g/@missing != 'v'"), + XPathValue::Boolean(false) + ); + assert_eq!( + eval_xpath(NEQ_DOC, "//g/@missing = 'v'"), + XPathValue::Boolean(false) + ); + assert_eq!( + eval_xpath(NEQ_DOC, "//g/@missing != 5"), + XPathValue::Boolean(false) + ); + assert_eq!( + eval_xpath(NEQ_DOC, "//nothere/@x != 'v'"), + XPathValue::Boolean(false) + ); + // A predicate on a missing attribute selects nothing. + assert_eq!(eval_count(NEQ_DOC, "//*[@missing != 'v']"), 0); + // string() of an absent attribute is still the empty string. + assert_eq!( + eval_xpath(NEQ_DOC, "string(//g/@missing)"), + XPathValue::String(String::new()) + ); + } + + // -- First-class attribute nodes (issue #47) ---------------------------- + + #[test] + fn test_attr_vs_attr_comparison_not_clobbered() { + // Two attribute node-sets in one comparison must each keep their own + // values: x = {1,2}, y = {3,3} has an unequal pair, so != is true. + let xml = "<r><a x='1' y='3'/><a x='2' y='3'/></r>"; + assert_eq!( + eval_xpath(xml, "//a/@x != //a/@y"), + XPathValue::Boolean(true) + ); + // x = {1,3}, y = {2,4} share no value, so = is false. + let xml2 = "<r><a x='1' y='2'/><a x='3' y='4'/></r>"; + assert_eq!( + eval_xpath(xml2, "//a/@x = //a/@y"), + XPathValue::Boolean(false) + ); + // And a genuinely shared value makes = true. + let xml3 = "<r><a x='1' y='2'/><a x='3' y='1'/></r>"; + assert_eq!( + eval_xpath(xml3, "//a/@x = //a/@y"), + XPathValue::Boolean(true) + ); + } + + #[test] + fn test_count_single_attribute_filter_path() { + // A single-match attribute step is a node-set of one attribute node, + // usable in node-set contexts like count(). + let xml = "<r><a href='u1'/><a/></r>"; + assert_eq!( + eval_xpath(xml, "count((//a)[1]/@href)"), + XPathValue::Number(1.0) + ); + assert_eq!(eval_xpath(xml, "count(//a/@href)"), XPathValue::Number(1.0)); + } + + #[test] + fn test_attribute_wildcard_counts_all_attributes() { + // @* yields one node per attribute — not one per element. + let xml = "<r><a x='1' y='2' z='3'/><b w='4'/></r>"; + assert_eq!(eval_xpath(xml, "count(//@*)"), XPathValue::Number(4.0)); + assert_eq!(eval_count(xml, "//a/@*"), 3); + // sum() over the wildcard sees every value. + assert_eq!(eval_xpath(xml, "sum(//@*)"), XPathValue::Number(10.0)); + } + + #[test] + fn test_attribute_wildcard_excludes_namespace_declarations() { + // xmlns/xmlns:* are namespace declarations, not attribute nodes + // (XPath 1.0 §5.3). + let xml = "<r xmlns:p='urn:p' id='1'><p:a p:x='2'/></r>"; + assert_eq!(eval_count(xml, "//@*"), 2); + } + + #[test] + fn test_attribute_node_name_functions() { + let xml = "<r xmlns:p='urn:p'><a p:href='u1'/></r>"; + assert_eq!( + eval_xpath(xml, "name(//a/@*)"), + XPathValue::String("p:href".to_owned()) + ); + assert_eq!( + eval_xpath(xml, "local-name(//a/@*)"), + XPathValue::String("href".to_owned()) + ); + assert_eq!( + eval_xpath(xml, "namespace-uri(//a/@*)"), + XPathValue::String("urn:p".to_owned()) + ); + } + + #[test] + fn test_attribute_parent_axis() { + let xml = "<r><a x='1'/></r>"; + assert_eq!( + eval_xpath(xml, "name(//a/@x/..)"), + XPathValue::String("a".to_owned()) + ); + assert_eq!(eval_count(xml, "//a/@x/ancestor::*"), 2); + } + + #[test] + fn test_attribute_predicate_on_attribute_step() { + // Predicates over attribute node-sets see the attribute's own + // string-value as the context node. + let xml = "<r><a x='1'/><a x='2'/><a x='3'/></r>"; + assert_eq!(eval_count(xml, "//a/@x[. > 1]"), 2); + assert_eq!( + eval_xpath(xml, "string(//a/@x[. = '2'])"), + XPathValue::String("2".to_owned()) + ); + } + + #[test] + fn test_union_of_elements_and_attributes() { + // Mixed node-sets sort in document order with an attribute directly + // after its owner element. + let xml = "<r><a x='1'>t</a></r>"; + let XPathValue::NodeSet(ns) = eval_xpath(xml, "//a | //a/@x") else { + panic!("expected node-set"); + }; + assert_eq!(ns.len(), 2); + assert!(!ns[0].is_attribute()); + assert!(ns[1].is_attribute()); + assert_eq!(ns[1].anchor(), ns[0].anchor()); + } + + #[test] + fn test_mixed_attribute_input_document_order() { + // Axis expansions from inputs that mix elements and attributes must + // come out in document order. + let xml = "<root><e a='1'><c1/><c2/></e></root>"; + let XPathValue::NodeSet(ns) = eval_xpath(xml, "(//e | //@a)/descendant-or-self::node()") + else { + panic!("expected node-set"); + }; + // Document order: e, @a, c1, c2. + assert_eq!(ns.len(), 4); + assert!(!ns[0].is_attribute()); + assert!(ns[1].is_attribute()); + assert_eq!( + eval_xpath(xml, "string(((//e | //@a)/descendant-or-self::node())[2])"), + XPathValue::String("1".to_owned()) + ); + + let xml2 = "<root><e a='1'><child/></e><after/></root>"; + assert_eq!( + eval_xpath(xml2, "name(((//e | //@a)/following::*)[1])"), + XPathValue::String("after".to_owned()) + ); + } + + #[test] + fn test_step_predicate_position_is_per_context_node() { + // XPath 1.0 §2.4: a step predicate filters the node-set generated + // for each context node separately — //a/b[1] selects the FIRST b + // of EVERY a, not the first of the merged set. + let xml = "<root><a><b>1</b><b>2</b></a><a><b>3</b><b>4</b></a></root>"; + assert_eq!(eval_count(xml, "//a/b[1]"), 2); + assert_eq!(eval_count(xml, "//a/b[last()]"), 2); + assert_eq!(eval_count(xml, "//a/b[2]"), 2); + // The parenthesized form applies the position globally. + assert_eq!(eval_count(xml, "(//a/b)[1]"), 1); + + // Same semantics over attribute steps. + let xml2 = "<root><a p='1' q='2'/><a p='3' q='4'/></root>"; + assert_eq!(eval_count(xml2, "//a/@*[1]"), 2); + assert_eq!(eval_count(xml2, "//a/@*[last()]"), 2); + assert_eq!(eval_xpath(xml2, "sum(//a/@*[1])"), XPathValue::Number(4.0)); + + // Predicates on the step after `//` also keep the proximity context + // of each descendant-or-self node. + assert_eq!(eval_count(xml, "//*[1]"), 4); + assert_eq!(eval_count(xml, "//a[last()]"), 1); + } + + #[test] + fn test_prefixed_attribute_name_test() { + // A prefixed attribute name test matches by literal prefix without + // bindings, and namespace-aware with bindings. + let xml = "<r xmlns:xl='http://www.w3.org/1999/xlink'><a xl:href='u1'/></r>"; + assert_eq!( + eval_xpath(xml, "string(//a/@xl:href)"), + XPathValue::String("u1".to_owned()) + ); + } + + #[test] + fn test_count_of_absent_attribute_filter_path() { + // count() over a filter-path attribute step with no matches must + // return 0, not a type error. + let xml = "<r><a href='u1'/><a/></r>"; + assert_eq!( + eval_xpath(xml, "count((//a)/@missing)"), + XPathValue::Number(0.0) + ); + } + + #[test] + fn test_neq_nodeset_nodeset() { + // g/b = {p, q}, h/b = {p, p}: the pair (q, p) is unequal. + assert_eq!( + eval_xpath(NEQ_DOC, "//g/b != //h/b"), + XPathValue::Boolean(true) + ); + // h/b vs itself: every pair is (p, p), so no unequal pair exists. + assert_eq!( + eval_xpath(NEQ_DOC, "//h/b != //h/b"), + XPathValue::Boolean(false) + ); + } + + #[test] + fn test_neq_in_predicate() { + // Only <g> has a b child whose string-value differs from 'p'. + assert_eq!(eval_count(NEQ_DOC, "//*[b != 'p']"), 1); + } + + #[test] + fn test_neq_nodeset_boolean_stays_negation() { + // Node-set vs boolean converts the node-set to a boolean first + // (XPath 1.0 §3.4), so `!=` IS plain negation there. + assert_eq!( + eval_xpath(NEQ_DOC, "//nothere != true()"), + XPathValue::Boolean(true) + ); + assert_eq!( + eval_xpath(NEQ_DOC, "//nothere != false()"), + XPathValue::Boolean(false) + ); + assert_eq!( + eval_xpath(NEQ_DOC, "//a != true()"), + XPathValue::Boolean(false) + ); + assert_eq!( + eval_xpath(NEQ_DOC, "//a != false()"), + XPathValue::Boolean(true) + ); + } + + // -- Filter-path expressions (issue #20) -------------------------------- + + #[test] + fn test_filter_path_attribute_value() { + // Regression test for issue #20: `string((//span/a)[1]/@href)` must + // return the attribute value, not the anchor's text content. + let xml = + "<r><span class='titleline'><a href='https://x.example/'>Link Text</a></span></r>"; + let result = eval_xpath(xml, "string((//span[@class='titleline']/a)[1]/@href)"); + assert_eq!(result, XPathValue::String("https://x.example/".to_owned())); + } + + #[test] + fn test_filter_path_element_step() { + // `(//a)/b` navigates to the b children; `(//a)[b]` filters the a + // elements by the existence of a b child. They must differ. + let xml = "<r><a><b>1</b><b>2</b></a><a/></r>"; + assert_eq!(eval_count(xml, "(//a)/b"), 2); + assert_eq!(eval_count(xml, "(//a)[b]"), 1); + } + + #[test] + fn test_filter_path_double_slash() { + let xml = "<r><a><b><c>deep</c></b></a><a><c>direct</c></a></r>"; + assert_eq!(eval_count(xml, "(//a)[1]//c"), 1); + assert_eq!(eval_count(xml, "(//a)//c"), 2); + } + + #[test] + fn test_filter_path_parent_step() { + let xml = "<r><a><b/></a></r>"; + let result = eval_xpath(xml, "name((//b)[1]/..)"); + assert_eq!(result, XPathValue::String("a".to_owned())); + } + + #[test] + fn test_filter_path_non_nodeset_is_type_error() { + // A location-path continuation on a non-node-set is a type error. + let doc = Document::parse_str("<r/>").unwrap(); + let root = doc.root_element().unwrap(); + let expr = parse("('str')/a").unwrap(); + let ctx = XPathContext::new(&doc, root); + let err = ctx.evaluate(&expr).unwrap_err(); + assert!( + matches!(err, XPathError::TypeError { .. }), + "expected TypeError, got: {err:?}" + ); + } + + #[test] + fn test_neq_scalar_nan() { + // NaN is unequal to everything, including itself. + assert_eq!( + eval_xpath("<r/>", "number('foo') != number('foo')"), + XPathValue::Boolean(true) + ); + assert_eq!( + eval_xpath("<r/>", "number('foo') = number('foo')"), + XPathValue::Boolean(false) + ); + } + + #[test] + fn test_comparison_lt_gt() { + let result = eval_xpath("<r/>", "1 < 2"); + assert_eq!(result, XPathValue::Boolean(true)); + + let result = eval_xpath("<r/>", "2 > 1"); + assert_eq!(result, XPathValue::Boolean(true)); + + let result = eval_xpath("<r/>", "2 < 1"); + assert_eq!(result, XPathValue::Boolean(false)); + } + + // -- Boolean operators -------------------------------------------------- + + #[test] + fn test_boolean_and() { + let result = eval_xpath("<r/>", "true() and false()"); + assert_eq!(result, XPathValue::Boolean(false)); + + let result = eval_xpath("<r/>", "true() and true()"); + assert_eq!(result, XPathValue::Boolean(true)); + } + + #[test] + fn test_boolean_or() { + let result = eval_xpath("<r/>", "true() or false()"); + assert_eq!(result, XPathValue::Boolean(true)); + + let result = eval_xpath("<r/>", "false() or false()"); + assert_eq!(result, XPathValue::Boolean(false)); + } + + // -- String functions --------------------------------------------------- + + #[test] + fn test_concat() { + let result = eval_xpath("<r/>", "concat('a', 'b')"); + assert_eq!(result, XPathValue::String("ab".to_owned())); + + let result = eval_xpath("<r/>", "concat('a', 'b', 'c')"); + assert_eq!(result, XPathValue::String("abc".to_owned())); + } + + #[test] + fn test_string_length() { + let result = eval_xpath("<r/>", "string-length('hello')"); + assert_eq!(result, XPathValue::Number(5.0)); + } + + #[test] + fn test_contains() { + let result = eval_xpath("<r/>", "contains('hello', 'ell')"); + assert_eq!(result, XPathValue::Boolean(true)); + + let result = eval_xpath("<r/>", "contains('hello', 'xyz')"); + assert_eq!(result, XPathValue::Boolean(false)); + } + + #[test] + fn test_starts_with() { + let result = eval_xpath("<r/>", "starts-with('hello', 'hel')"); + assert_eq!(result, XPathValue::Boolean(true)); + + let result = eval_xpath("<r/>", "starts-with('hello', 'xyz')"); + assert_eq!(result, XPathValue::Boolean(false)); + } + + #[test] + fn test_substring() { + // substring('12345', 2, 3) = '234' + let result = eval_xpath("<r/>", "substring('12345', 2, 3)"); + assert_eq!(result, XPathValue::String("234".to_owned())); + + // substring('12345', 2) = '2345' + let result = eval_xpath("<r/>", "substring('12345', 2)"); + assert_eq!(result, XPathValue::String("2345".to_owned())); + } + + #[test] + fn test_normalize_space() { + let result = eval_xpath("<r/>", "normalize-space(' hello world ')"); + assert_eq!(result, XPathValue::String("hello world".to_owned())); + } + + #[test] + fn test_translate() { + let result = eval_xpath("<r/>", "translate('bar', 'abc', 'ABC')"); + assert_eq!(result, XPathValue::String("BAr".to_owned())); + } + + #[test] + fn test_substring_before_after() { + let result = eval_xpath("<r/>", "substring-before('1999/04/01', '/')"); + assert_eq!(result, XPathValue::String("1999".to_owned())); + + let result = eval_xpath("<r/>", "substring-after('1999/04/01', '/')"); + assert_eq!(result, XPathValue::String("04/01".to_owned())); + } + + // -- Number functions --------------------------------------------------- + + #[test] + fn test_floor() { + let result = eval_xpath("<r/>", "floor(1.5)"); + assert_eq!(result, XPathValue::Number(1.0)); + + let result = eval_xpath("<r/>", "floor(-1.5)"); + assert_eq!(result, XPathValue::Number(-2.0)); + } + + #[test] + fn test_ceiling() { + let result = eval_xpath("<r/>", "ceiling(1.5)"); + assert_eq!(result, XPathValue::Number(2.0)); + + let result = eval_xpath("<r/>", "ceiling(-1.5)"); + assert_eq!(result, XPathValue::Number(-1.0)); + } + + #[test] + fn test_round() { + let result = eval_xpath("<r/>", "round(1.5)"); + assert_eq!(result, XPathValue::Number(2.0)); + + let result = eval_xpath("<r/>", "round(-0.5)"); + assert_eq!(result, XPathValue::Number(0.0)); + + let result = eval_xpath("<r/>", "round(2.5)"); + assert_eq!(result, XPathValue::Number(3.0)); + } + + // -- count(), position(), last() ---------------------------------------- + + #[test] + fn test_count() { + let result = eval_xpath("<r><a/><b/><c/></r>", "count(*)"); + assert_eq!(result, XPathValue::Number(3.0)); + } + + #[test] + fn test_position_and_last() { + // position() and last() in the default singleton context + let result = eval_xpath("<r/>", "position()"); + assert_eq!(result, XPathValue::Number(1.0)); + + let result = eval_xpath("<r/>", "last()"); + assert_eq!(result, XPathValue::Number(1.0)); + } + + // -- Path evaluation ---------------------------------------------------- + + #[test] + fn test_simple_child_path() { + let count = eval_count("<r><a/><b/><c/></r>", "a"); + assert_eq!(count, 1); + } + + #[test] + fn test_root_path() { + let xml = "<root><child>text</child></root>"; + let result = eval_xpath_from_doc_root(xml, "/root/child"); + match &result { + XPathValue::NodeSet(ns) => assert_eq!(ns.len(), 1), + _ => panic!("expected node-set"), + } + } + + #[test] + fn test_descendant_axis() { + // //child matches anywhere in the tree + let xml = "<r><a><b/></a></r>"; + let result = eval_xpath_from_doc_root(xml, "//b"); + match &result { + XPathValue::NodeSet(ns) => assert_eq!(ns.len(), 1), + _ => panic!("expected node-set"), + } + } + + #[test] + fn test_parent_axis() { + // child/.. should go back to the parent + let xml = "<r><a><b/></a></r>"; + let doc = Document::parse_str(xml).unwrap(); + let root_elem = doc.root_element().unwrap(); + // Navigate to <a> + let a = doc.children(root_elem).next().unwrap(); + // Navigate to <b> + let b = doc.children(a).next().unwrap(); + + let expr = parse("..").unwrap(); + let ctx = XPathContext::new(&doc, b); + let result = ctx.evaluate(&expr).unwrap(); + match &result { + XPathValue::NodeSet(ns) => { + assert_eq!(ns.len(), 1); + assert_eq!(ns[0], XPathNode::Node(a)); + } + _ => panic!("expected node-set"), + } + } + + #[test] + fn test_self_axis() { + let xml = "<r><a/></r>"; + let doc = Document::parse_str(xml).unwrap(); + let root_elem = doc.root_element().unwrap(); + + let expr = parse(".").unwrap(); + let ctx = XPathContext::new(&doc, root_elem); + let result = ctx.evaluate(&expr).unwrap(); + match &result { + XPathValue::NodeSet(ns) => { + assert_eq!(ns.len(), 1); + assert_eq!(ns[0], XPathNode::Node(root_elem)); + } + _ => panic!("expected node-set"), + } + } + + // -- Predicates --------------------------------------------------------- + + #[test] + fn test_positional_predicate() { + let xml = "<r><a/><b/><c/></r>"; + let count = eval_count(xml, "*[1]"); + assert_eq!(count, 1); + } + + #[test] + fn test_last_predicate() { + let xml = "<r><a/><b/><c/></r>"; + let count = eval_count(xml, "*[last()]"); + assert_eq!(count, 1); + } + + #[test] + fn test_attribute_predicate() { + let xml = r#"<r><a id="x"/><a id="y"/></r>"#; + let doc = Document::parse_str(xml).unwrap(); + let root_elem = doc.root_element().unwrap(); + let expr = parse("a[@id='x']").unwrap(); + let ctx = XPathContext::new(&doc, root_elem); + let result = ctx.evaluate(&expr).unwrap(); + match &result { + XPathValue::NodeSet(ns) => { + assert_eq!(ns.len(), 1); + // Check it's the right element + assert_eq!(doc.attribute(ns[0].anchor(), "id"), Some("x")); + } + _ => panic!("expected node-set"), + } + } + + // -- Variables ----------------------------------------------------------- + + #[test] + fn test_variable() { + let xml = "<r/>"; + let doc = Document::parse_str(xml).unwrap(); + let root = doc.root_element().unwrap(); + let expr = parse("$x + 1").unwrap(); + let mut ctx = XPathContext::new(&doc, root); + ctx.set_variable("x", XPathValue::Number(41.0)); + let result = ctx.evaluate(&expr).unwrap(); + assert_eq!(result, XPathValue::Number(42.0)); + } + + #[test] + fn test_undefined_variable_error() { + let xml = "<r/>"; + let doc = Document::parse_str(xml).unwrap(); + let root = doc.root_element().unwrap(); + let expr = parse("$undefined").unwrap(); + let ctx = XPathContext::new(&doc, root); + let result = ctx.evaluate(&expr); + assert!(result.is_err()); + } + + // -- Union --------------------------------------------------------------- + + #[test] + fn test_union() { + let xml = "<r><a/><b/></r>"; + let count = eval_count(xml, "a | b"); + assert_eq!(count, 2); + } + + // -- Complex expression -------------------------------------------------- + + #[test] + fn test_complex_book_price() { + let xml = r"<store> + <book><title>A</title><price>30</price></book> + <book><title>B</title><price>40</price></book> + </store>"; + let doc = Document::parse_str(xml).unwrap(); + let root_elem = doc.root_element().unwrap(); + // Count books with price > 35 + let expr = parse("count(book[price > 35])").unwrap(); + let ctx = XPathContext::new(&doc, root_elem); + let result = ctx.evaluate(&expr).unwrap(); + assert_eq!(result, XPathValue::Number(1.0)); + } + + // -- String value of nodes ----------------------------------------------- + + #[test] + fn test_string_function_on_context() { + let xml = "<r>hello</r>"; + let result = eval_xpath(xml, "string()"); + assert_eq!(result, XPathValue::String("hello".to_owned())); + } + + // -- Deep path traversal ------------------------------------------------- + + #[test] + fn test_deep_path_traversal() { + let xml = "<a><b><c><d>deep</d></c></b></a>"; + let result = eval_xpath_from_doc_root(xml, "/a/b/c/d"); + match &result { + XPathValue::NodeSet(ns) => { + assert_eq!(ns.len(), 1); + let doc = Document::parse_str(xml).unwrap(); + assert_eq!(doc.text_content(ns[0].anchor()), "deep"); + } + _ => panic!("expected node-set"), + } + } + + // -- Unary negation ------------------------------------------------------ + + #[test] + fn test_unary_neg() { + let result = eval_xpath("<r/>", "-(3)"); + assert_eq!(result, XPathValue::Number(-3.0)); + } + + // -- Not function -------------------------------------------------------- + + #[test] + fn test_not() { + let result = eval_xpath("<r/>", "not(true())"); + assert_eq!(result, XPathValue::Boolean(false)); + + let result = eval_xpath("<r/>", "not(false())"); + assert_eq!(result, XPathValue::Boolean(true)); + } + + // -- Name functions ------------------------------------------------------ + + #[test] + fn test_name_function() { + let xml = "<root/>"; + let result = eval_xpath(xml, "name()"); + assert_eq!(result, XPathValue::String("root".to_owned())); + } + + #[test] + fn test_local_name_function() { + let xml = "<root/>"; + let result = eval_xpath(xml, "local-name()"); + assert_eq!(result, XPathValue::String("root".to_owned())); + } + + // -- Sum function -------------------------------------------------------- + + #[test] + fn test_sum() { + let xml = "<r><n>1</n><n>2</n><n>3</n></r>"; + let result = eval_xpath(xml, "sum(n)"); + assert_eq!(result, XPathValue::Number(6.0)); + } + + // -- Following/Preceding sibling axes ------------------------------------ + + #[test] + fn test_following_sibling() { + let xml = "<r><a/><b/><c/></r>"; + let doc = Document::parse_str(xml).unwrap(); + let root_elem = doc.root_element().unwrap(); + let a = doc.children(root_elem).next().unwrap(); + + let expr = parse("following-sibling::*").unwrap(); + let ctx = XPathContext::new(&doc, a); + let result = ctx.evaluate(&expr).unwrap(); + match &result { + XPathValue::NodeSet(ns) => assert_eq!(ns.len(), 2), // b and c + _ => panic!("expected node-set"), + } + } + + #[test] + fn test_preceding_sibling() { + let xml = "<r><a/><b/><c/></r>"; + let doc = Document::parse_str(xml).unwrap(); + let root_elem = doc.root_element().unwrap(); + let children: Vec<_> = doc.children(root_elem).collect(); + let c = children[2]; // <c/> + + let expr = parse("preceding-sibling::*").unwrap(); + let ctx = XPathContext::new(&doc, c); + let result = ctx.evaluate(&expr).unwrap(); + match &result { + XPathValue::NodeSet(ns) => assert_eq!(ns.len(), 2), // a and b + _ => panic!("expected node-set"), + } + } + + // -- id() function ------------------------------------------------------- + + #[test] + fn test_xpath_id_single() { + let mut doc = Document::parse_str( + r#"<root><item id="x">Hello</item><item id="y">World</item></root>"#, + ) + .unwrap(); + // Populate the id_map + let root = doc.root_element().unwrap(); + let children: Vec<_> = doc.children(root).collect(); + doc.set_id("x", children[0]); + doc.set_id("y", children[1]); + + let expr = parse("id('x')").unwrap(); + let ctx = XPathContext::new(&doc, root); + let result = ctx.evaluate(&expr).unwrap(); + match &result { + XPathValue::NodeSet(ns) => { + assert_eq!(ns.len(), 1); + assert_eq!(doc.node_name(ns[0].anchor()), Some("item")); + assert_eq!(doc.text_content(ns[0].anchor()), "Hello"); + } + other => panic!("expected node-set, got {other:?}"), + } + } + + #[test] + fn test_xpath_id_multiple_space_separated() { + let mut doc = + Document::parse_str(r#"<root><a id="p">1</a><b id="q">2</b><c id="r">3</c></root>"#) + .unwrap(); + let root = doc.root_element().unwrap(); + let children: Vec<_> = doc.children(root).collect(); + doc.set_id("p", children[0]); + doc.set_id("q", children[1]); + doc.set_id("r", children[2]); + + let expr = parse("id('p r')").unwrap(); + let ctx = XPathContext::new(&doc, root); + let result = ctx.evaluate(&expr).unwrap(); + match &result { + XPathValue::NodeSet(ns) => { + assert_eq!(ns.len(), 2); + assert_eq!(doc.node_name(ns[0].anchor()), Some("a")); + assert_eq!(doc.node_name(ns[1].anchor()), Some("c")); + } + other => panic!("expected node-set, got {other:?}"), + } + } + + #[test] + fn test_xpath_id_unknown_returns_empty() { + let doc = Document::parse_str("<root/>").unwrap(); + let root = doc.root_element().unwrap(); + + let expr = parse("id('nonexistent')").unwrap(); + let ctx = XPathContext::new(&doc, root); + let result = ctx.evaluate(&expr).unwrap(); + match &result { + XPathValue::NodeSet(ns) => assert!(ns.is_empty()), + other => panic!("expected empty node-set, got {other:?}"), + } + } + + // -- Namespace axis ------------------------------------------------------- + + #[test] + fn test_namespace_axis_with_declaration() { + let xml = r#"<root xmlns:ns="http://example.com"><child/></root>"#; + let doc = Document::parse_str(xml).unwrap(); + let root = doc.root_element().unwrap(); + + // namespace::ns should match because ns is in scope + let expr = parse("namespace::ns").unwrap(); + let ctx = XPathContext::new(&doc, root); + let result = ctx.evaluate(&expr).unwrap(); + match &result { + XPathValue::NodeSet(ns) => assert!(!ns.is_empty(), "expected namespace::ns to match"), + other => panic!("expected node-set, got {other:?}"), + } + } + + #[test] + fn test_namespace_axis_inherited() { + let xml = r#"<root xmlns:ns="http://example.com"><child/></root>"#; + let doc = Document::parse_str(xml).unwrap(); + let root = doc.root_element().unwrap(); + let child = doc.first_child(root).unwrap(); + + // namespace::ns should be in scope on child (inherited from root) + let expr = parse("namespace::ns").unwrap(); + let ctx = XPathContext::new(&doc, child); + let result = ctx.evaluate(&expr).unwrap(); + match &result { + XPathValue::NodeSet(ns) => { + assert!(!ns.is_empty(), "expected namespace::ns to be inherited"); + } + other => panic!("expected node-set, got {other:?}"), + } + } + + #[test] + fn test_namespace_axis_xml_always_in_scope() { + let xml = "<root/>"; + let doc = Document::parse_str(xml).unwrap(); + let root = doc.root_element().unwrap(); + + // namespace::xml should always be in scope + let expr = parse("namespace::xml").unwrap(); + let ctx = XPathContext::new(&doc, root); + let result = ctx.evaluate(&expr).unwrap(); + match &result { + XPathValue::NodeSet(ns) => { + assert!( + !ns.is_empty(), + "expected namespace::xml to always be in scope" + ); + } + other => panic!("expected node-set, got {other:?}"), + } + } + + #[test] + fn test_namespace_axis_wildcard() { + let xml = r#"<root xmlns:a="http://a" xmlns:b="http://b"><child/></root>"#; + let doc = Document::parse_str(xml).unwrap(); + let root = doc.root_element().unwrap(); + + // namespace::* should return non-empty (a, b, xml are all in scope) + let expr = parse("namespace::*").unwrap(); + let ctx = XPathContext::new(&doc, root); + let result = ctx.evaluate(&expr).unwrap(); + match &result { + XPathValue::NodeSet(ns) => { + assert!(!ns.is_empty(), "expected namespace::* to match"); + } + other => panic!("expected node-set, got {other:?}"), + } + } + + #[test] + fn test_namespace_axis_nonexistent_prefix() { + let xml = "<root/>"; + let doc = Document::parse_str(xml).unwrap(); + let root = doc.root_element().unwrap(); + + // namespace::nonexistent should return empty + let expr = parse("namespace::nonexistent").unwrap(); + let ctx = XPathContext::new(&doc, root); + let result = ctx.evaluate(&expr).unwrap(); + match &result { + XPathValue::NodeSet(ns) => { + assert!(ns.is_empty(), "expected no match for nonexistent prefix"); + } + other => panic!("expected node-set, got {other:?}"), + } + } + + #[test] + fn test_namespace_axis_on_text_node() { + let xml = "<root>text</root>"; + let doc = Document::parse_str(xml).unwrap(); + let root = doc.root_element().unwrap(); + let text = doc.first_child(root).unwrap(); + + // namespace axis on a text node should return empty + let expr = parse("namespace::*").unwrap(); + let ctx = XPathContext::new(&doc, text); + let result = ctx.evaluate(&expr).unwrap(); + match &result { + XPathValue::NodeSet(ns) => { + assert!(ns.is_empty(), "namespace axis on text node should be empty"); + } + other => panic!("expected node-set, got {other:?}"), + } + } + + #[test] + fn test_namespace_axis_default_namespace() { + let xml = r#"<root xmlns="http://default"><child/></root>"#; + let doc = Document::parse_str(xml).unwrap(); + let root = doc.root_element().unwrap(); + + // namespace::* should include the default namespace + let expr = parse("namespace::*").unwrap(); + let ctx = XPathContext::new(&doc, root); + let result = ctx.evaluate(&expr).unwrap(); + match &result { + XPathValue::NodeSet(ns) => { + assert!(!ns.is_empty(), "expected default namespace to be in scope"); + } + other => panic!("expected node-set, got {other:?}"), + } + } +} diff --git a/browser/vendor/xmloxide/src/xpath/lexer.rs b/browser/vendor/xmloxide/src/xpath/lexer.rs new file mode 100644 index 000000000..2014563d4 --- /dev/null +++ b/browser/vendor/xmloxide/src/xpath/lexer.rs @@ -0,0 +1,1099 @@ +//! `XPath` 1.0 expression tokenizer. +//! +//! This module implements a lexer for `XPath` 1.0 expressions as specified in +//! <https://www.w3.org/TR/xpath-10/#exprlex>. The lexer converts an `XPath` +//! expression string into a sequence of [`Token`]s that can be consumed by +//! the parser. +//! +//! # Disambiguation Rules +//! +//! The `XPath` 1.0 specification (section 3.7) defines several disambiguation +//! rules that the lexer must apply: +//! +//! - `*` is treated as a multiply operator (not a name test) when the +//! preceding token could end an operand. +//! - A name followed by `(` is a function name or node type test. +//! - A name followed by `::` is an axis name. +//! - Otherwise, a name is a `NameTest`. + +use std::fmt; + +/// An error that occurred during `XPath` lexing or parsing. +#[derive(Debug, Clone)] +pub struct XPathError { + /// Human-readable error message. + pub message: String, + /// 0-based byte offset in the expression where the error occurred. + pub position: usize, +} + +impl fmt::Display for XPathError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "XPath error at position {}: {}", + self.position, self.message + ) + } +} + +impl std::error::Error for XPathError {} + +/// The set of node type names recognized as node type tests. +/// +/// When one of these names appears before `(`, it is a node type test rather +/// than a function call. See `XPath` 1.0 section 3.7. +const NODE_TYPE_NAMES: &[&str] = &["comment", "text", "processing-instruction", "node"]; + +/// A token produced by the `XPath` lexer. +/// +/// See `XPath` 1.0 section 3.7 for the full token grammar. +#[derive(Debug, Clone, PartialEq)] +pub enum Token { + /// `(` -- left parenthesis. + LeftParen, + /// `)` -- right parenthesis. + RightParen, + /// `[` -- left bracket (predicate open). + LeftBracket, + /// `]` -- right bracket (predicate close). + RightBracket, + /// `.` -- current node (abbreviated step). + Dot, + /// `..` -- parent node (abbreviated step). + DotDot, + /// `@` -- attribute axis abbreviation. + At, + /// `,` -- argument separator in function calls. + Comma, + /// `::` -- axis separator. + ColonColon, + /// `/` -- child step separator. + Slash, + /// `//` -- descendant-or-self step abbreviation. + DoubleSlash, + /// `|` -- union operator. + Pipe, + /// `+` -- addition operator. + Plus, + /// `-` -- subtraction or unary negation. + Minus, + /// `*` -- multiplication operator (disambiguation from name test + /// is handled by the lexer based on context). + Star, + /// `=` -- equality comparison. + Equal, + /// `!=` -- inequality comparison. + NotEqual, + /// `<` -- less-than comparison. + LessThan, + /// `<=` -- less-than-or-equal comparison. + LessThanEqual, + /// `>` -- greater-than comparison. + GreaterThan, + /// `>=` -- greater-than-or-equal comparison. + GreaterThanEqual, + /// `and` keyword operator. + And, + /// `or` keyword operator. + Or, + /// `mod` keyword operator. + Mod, + /// `div` keyword operator. + Div, + /// A numeric literal (e.g., `42`, `3.5`, `.5`). + Number(f64), + /// A string literal (e.g., `"hello"` or `'world'`). + Literal(String), + /// A qualified name used as a name test (e.g., `foo`, `svg:rect`). + Name(String), + /// A variable reference (e.g., `$var`). The string is the name without `$`. + VariableReference(String), + /// A function name (name that appeared before `(`). + FunctionName(String), + /// A node type test keyword (e.g., `node`, `text`, `comment`, + /// `processing-instruction`). + NodeType(String), + /// An axis name (name that appeared before `::`). + AxisName(String), +} + +impl fmt::Display for Token { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::LeftParen => f.write_str("("), + Self::RightParen => f.write_str(")"), + Self::LeftBracket => f.write_str("["), + Self::RightBracket => f.write_str("]"), + Self::Dot => f.write_str("."), + Self::DotDot => f.write_str(".."), + Self::At => f.write_str("@"), + Self::Comma => f.write_str(","), + Self::ColonColon => f.write_str("::"), + Self::Slash => f.write_str("/"), + Self::DoubleSlash => f.write_str("//"), + Self::Pipe => f.write_str("|"), + Self::Plus => f.write_str("+"), + Self::Minus => f.write_str("-"), + Self::Star => f.write_str("*"), + Self::Equal => f.write_str("="), + Self::NotEqual => f.write_str("!="), + Self::LessThan => f.write_str("<"), + Self::LessThanEqual => f.write_str("<="), + Self::GreaterThan => f.write_str(">"), + Self::GreaterThanEqual => f.write_str(">="), + Self::And => f.write_str("and"), + Self::Or => f.write_str("or"), + Self::Mod => f.write_str("mod"), + Self::Div => f.write_str("div"), + Self::Number(n) => write!(f, "{n}"), + Self::Literal(s) => write!(f, "\"{s}\""), + Self::Name(s) | Self::FunctionName(s) | Self::NodeType(s) | Self::AxisName(s) => { + write!(f, "{s}") + } + Self::VariableReference(s) => write!(f, "${s}"), + } + } +} + +/// `XPath` 1.0 expression tokenizer. +/// +/// The lexer processes an `XPath` expression string and produces a sequence of +/// [`Token`]s. It handles the disambiguation rules from `XPath` 1.0 section 3.7 +/// to correctly distinguish between operators and name tests for `*`, and +/// between function names, node type tests, axis names, and plain name tests. +/// +/// # Examples +/// +/// ```ignore +/// use xmloxide::xpath::lexer::Lexer; +/// +/// let mut lexer = Lexer::new("child::p[@class='intro']"); +/// let tokens = lexer.tokenize().unwrap(); +/// ``` +pub struct Lexer<'a> { + /// The input expression as bytes for efficient indexing. + input: &'a [u8], + /// Current byte offset into the input. + pos: usize, +} + +impl<'a> Lexer<'a> { + /// Creates a new lexer for the given `XPath` expression string. + #[must_use] + pub fn new(input: &'a str) -> Self { + Self { + input: input.as_bytes(), + pos: 0, + } + } + + /// Tokenizes the entire input expression into a sequence of tokens. + /// + /// Applies the `XPath` 1.0 disambiguation rules (section 3.7) to correctly + /// classify tokens based on their context. + /// + /// # Errors + /// + /// Returns [`XPathError`] if the input contains an invalid token, such as + /// an unterminated string literal or an unexpected character. + pub fn tokenize(&mut self) -> Result<Vec<Token>, XPathError> { + let mut raw_tokens = Vec::new(); + + loop { + self.skip_whitespace(); + if self.pos >= self.input.len() { + break; + } + let token = self.next_raw_token()?; + raw_tokens.push(token); + } + + Ok(Self::disambiguate(raw_tokens)) + } + + /// Reads the next raw token from the input. + /// + /// At this stage, names are all emitted as `Name`, and `*` is always + /// emitted as `Star`. Disambiguation happens in a second pass. + fn next_raw_token(&mut self) -> Result<Token, XPathError> { + let ch = self + .peek_byte() + .ok_or_else(|| self.error("unexpected end of input"))?; + + match ch { + b'(' => { + self.advance(); + Ok(Token::LeftParen) + } + b')' => { + self.advance(); + Ok(Token::RightParen) + } + b'[' => { + self.advance(); + Ok(Token::LeftBracket) + } + b']' => { + self.advance(); + Ok(Token::RightBracket) + } + b'@' => { + self.advance(); + Ok(Token::At) + } + b',' => { + self.advance(); + Ok(Token::Comma) + } + b'|' => { + self.advance(); + Ok(Token::Pipe) + } + b'+' => { + self.advance(); + Ok(Token::Plus) + } + b'-' => { + self.advance(); + Ok(Token::Minus) + } + b'=' => { + self.advance(); + Ok(Token::Equal) + } + b'*' => { + self.advance(); + Ok(Token::Star) + } + b':' => self.read_colon_colon(), + b'.' => self.read_dot_or_number(), + b'/' => Ok(self.read_slash()), + b'!' => self.read_not_equal(), + b'<' => Ok(self.read_less_than()), + b'>' => Ok(self.read_greater_than()), + b'"' | b'\'' => self.read_string_literal(), + b'$' => self.read_variable_reference(), + b'0'..=b'9' => self.read_number(), + _ if is_name_start_char(ch) => Ok(self.read_name()), + _ => Err(self.error(&format!("unexpected character '{}'", char::from(ch)))), + } + } + + /// Reads a `.` (`Dot`) or `..` (`DotDot`) token, or a number starting with `.`. + fn read_dot_or_number(&mut self) -> Result<Token, XPathError> { + self.advance(); // consume the first '.' + + if self.peek_byte() == Some(b'.') { + self.advance(); + return Ok(Token::DotDot); + } + + // Check if this is a number like .5 + if matches!(self.peek_byte(), Some(b'0'..=b'9')) { + let start = self.pos - 1; // include the '.' + self.advance_while(|b| b.is_ascii_digit()); + let text = self.slice_from(start); + let value = text + .parse::<f64>() + .map_err(|_| make_error(start, &format!("invalid number literal: {text}")))?; + return Ok(Token::Number(value)); + } + + Ok(Token::Dot) + } + + /// Reads a `/` or `//` token. + fn read_slash(&mut self) -> Token { + self.advance(); // consume '/' + if self.peek_byte() == Some(b'/') { + self.advance(); + Token::DoubleSlash + } else { + Token::Slash + } + } + + /// Reads the `::` (axis separator) token. + fn read_colon_colon(&mut self) -> Result<Token, XPathError> { + let start = self.pos; + self.advance(); // consume first ':' + if self.peek_byte() == Some(b':') { + self.advance(); + Ok(Token::ColonColon) + } else { + Err(make_error(start, "expected ':' after ':'")) + } + } + + /// Reads the `!=` token. + fn read_not_equal(&mut self) -> Result<Token, XPathError> { + let start = self.pos; + self.advance(); // consume '!' + if self.peek_byte() == Some(b'=') { + self.advance(); + Ok(Token::NotEqual) + } else { + Err(make_error(start, "expected '=' after '!'")) + } + } + + /// Reads `<` or `<=`. + fn read_less_than(&mut self) -> Token { + self.advance(); // consume '<' + if self.peek_byte() == Some(b'=') { + self.advance(); + Token::LessThanEqual + } else { + Token::LessThan + } + } + + /// Reads `>` or `>=`. + fn read_greater_than(&mut self) -> Token { + self.advance(); // consume '>' + if self.peek_byte() == Some(b'=') { + self.advance(); + Token::GreaterThanEqual + } else { + Token::GreaterThan + } + } + + /// Reads a string literal (single or double quoted). + /// + /// See `XPath` 1.0 section 3.5: a `Literal` is `'"' [^"]* '"'` or + /// `"'" [^']* "'"`. + fn read_string_literal(&mut self) -> Result<Token, XPathError> { + let start = self.pos; + let quote = self + .peek_byte() + .ok_or_else(|| self.error("unexpected end of input"))?; + self.advance(); // consume opening quote + + let content_start = self.pos; + self.advance_while(|b| b != quote); + + if self.pos >= self.input.len() { + return Err(make_error(start, "unterminated string literal")); + } + + let content = self.slice_from(content_start).to_string(); + self.advance(); // consume closing quote + + Ok(Token::Literal(content)) + } + + /// Reads a variable reference (`$name`). + /// + /// See `XPath` 1.0 section 3.1. + fn read_variable_reference(&mut self) -> Result<Token, XPathError> { + let start = self.pos; + self.advance(); // consume '$' + + if !self.peek_byte().is_some_and(is_name_start_char) { + return Err(make_error(start, "expected name after '$'")); + } + + let name_start = self.pos; + self.advance_while(is_name_char); + + // Handle QName (prefix:localname) + if self.peek_byte() == Some(b':') + && self + .peek_byte_at(self.pos + 1) + .is_some_and(|b| b != b':' && is_name_start_char(b)) + { + self.advance(); // consume ':' + self.advance_while(is_name_char); + let full_name = self.slice_from(name_start); + return Ok(Token::VariableReference(full_name.to_string())); + } + + let name = self.slice_from(name_start); + Ok(Token::VariableReference(name.to_string())) + } + + /// Reads a numeric literal. + /// + /// See `XPath` 1.0 section 3.5: a `Number` is `Digits ('.' Digits?)?` or + /// `'.' Digits`. + fn read_number(&mut self) -> Result<Token, XPathError> { + let start = self.pos; + self.advance_while(|b| b.is_ascii_digit()); + + // Check for decimal point + if self.peek_byte() == Some(b'.') { + self.advance(); + self.advance_while(|b| b.is_ascii_digit()); + } + + let text = self.slice_from(start); + let value = text + .parse::<f64>() + .map_err(|_| make_error(start, &format!("invalid number literal: {text}")))?; + Ok(Token::Number(value)) + } + + /// Reads a name token (`NCName` or `QName`). + /// + /// At this stage, the token is always emitted as `Name`. The disambiguation + /// pass will reclassify it as `FunctionName`, `NodeType`, or `AxisName` + /// based on the following token. + fn read_name(&mut self) -> Token { + let start = self.pos; + self.advance_while(is_name_char); + + // Check for QName (prefix:localname) or prefix:* -- but not prefix::axis + if self.peek_byte() == Some(b':') { + let next = self.peek_byte_at(self.pos + 1); + if next.is_some_and(|b| b != b':' && is_name_start_char(b)) { + // prefix:localname + self.advance(); // consume ':' + self.advance_while(is_name_char); + } else if next == Some(b'*') { + // prefix:* (namespace wildcard) + self.advance(); // consume ':' + self.advance(); // consume '*' + } + } + + let name = self.slice_from(start); + Token::Name(name.to_string()) + } + + /// Applies the `XPath` 1.0 disambiguation rules (section 3.7). + /// + /// This function reclassifies raw tokens based on their context: + /// - `*` after an operand-ending token becomes `Star` (multiply operator); + /// otherwise it remains `Name("*")` (name test). + /// - A `Name` followed by `(` becomes `FunctionName` or `NodeType`. + /// - A `Name` followed by `::` becomes `AxisName`. + /// - `and`, `or`, `mod`, `div` after operand-ending tokens become operators. + fn disambiguate(raw_tokens: Vec<Token>) -> Vec<Token> { + let len = raw_tokens.len(); + let mut result = Vec::with_capacity(len); + + for (i, token) in raw_tokens.into_iter().enumerate() { + let preceding_is_operand = if i == 0 { + false + } else { + is_operand_ending(&result[result.len() - 1]) + }; + + match token { + Token::Star if !preceding_is_operand => { + // When * is not preceded by an operand-ending token, it's + // a name test wildcard, not a multiply operator. + result.push(Token::Name("*".to_string())); + } + other => result.push(other), + } + } + + // Second pass: now that we have the full list, apply name disambiguation. + disambiguate_names(&mut result); + + result + } + + // --- Utility methods --- + + /// Returns the byte at the current position, or `None` if at end. + fn peek_byte(&self) -> Option<u8> { + self.input.get(self.pos).copied() + } + + /// Returns the byte at the given position, or `None` if out of bounds. + fn peek_byte_at(&self, pos: usize) -> Option<u8> { + self.input.get(pos).copied() + } + + /// Advances the position by one byte. + fn advance(&mut self) { + self.pos += 1; + } + + /// Advances while the predicate holds for the current byte. + fn advance_while<F: Fn(u8) -> bool>(&mut self, pred: F) { + while self.pos < self.input.len() && pred(self.input[self.pos]) { + self.pos += 1; + } + } + + /// Skips ASCII whitespace characters. + fn skip_whitespace(&mut self) { + self.advance_while(|b| b.is_ascii_whitespace()); + } + + /// Returns the substring from `start` to the current position. + fn slice_from(&self, start: usize) -> &str { + // The input was originally a &str so it is valid UTF-8. We only + // split at ASCII byte boundaries, preserving UTF-8 validity. + // Using the safe `from_utf8` to avoid any unsafe code. + std::str::from_utf8(&self.input[start..self.pos]).unwrap_or("") + } + + /// Creates an error at the current position. + fn error(&self, message: &str) -> XPathError { + make_error(self.pos, message) + } +} + +/// Creates an [`XPathError`] at the given position. +fn make_error(position: usize, message: &str) -> XPathError { + XPathError { + message: message.to_string(), + position, + } +} + +/// Second-pass disambiguation for names, function names, node types, +/// axis names, and keyword operators. +fn disambiguate_names(tokens: &mut [Token]) { + let len = tokens.len(); + let mut i = 0; + while i < len { + // Check if current token is a Name + if let Token::Name(ref name) = tokens[i] { + let name_clone = name.clone(); + + // Determine if the preceding token ends an operand + let preceding_is_operand = if i == 0 { + false + } else { + is_operand_ending(&tokens[i - 1]) + }; + + // Look ahead to next token (whitespace is already stripped). + let next = tokens.get(i + 1); + + if preceding_is_operand { + // After an operand, these names are operators. + match name_clone.as_str() { + "and" => tokens[i] = Token::And, + "or" => tokens[i] = Token::Or, + "mod" => tokens[i] = Token::Mod, + "div" => tokens[i] = Token::Div, + "*" => { + // * after operand is multiply + tokens[i] = Token::Star; + } + _ => {} + } + } else if matches!(next, Some(Token::LeftParen)) { + // Name followed by '(' -- function name or node type test + if NODE_TYPE_NAMES.contains(&name_clone.as_str()) { + tokens[i] = Token::NodeType(name_clone); + } else { + tokens[i] = Token::FunctionName(name_clone); + } + } else if matches!(next, Some(Token::ColonColon)) { + // Name followed by '::' -- axis name + tokens[i] = Token::AxisName(name_clone); + } + } + + i += 1; + } +} + +/// Returns `true` if the given token could end an operand. +/// +/// Per `XPath` 1.0 section 3.7, the preceding token determines whether `*` is +/// a multiply operator. If the preceding token is one that could end an +/// expression or name test, then `*` is multiply. If there is no preceding +/// token, or the preceding token is an operator or punctuation, then `*` is +/// a name test (wildcard). +fn is_operand_ending(token: &Token) -> bool { + matches!( + token, + Token::RightParen + | Token::RightBracket + | Token::Dot + | Token::DotDot + | Token::Number(_) + | Token::Literal(_) + | Token::Name(_) + | Token::VariableReference(_) + | Token::NodeType(_) + | Token::Star + ) +} + +/// Returns `true` if the byte is a valid name start character. +/// +/// For simplicity, we accept ASCII letters and `_`. A full implementation +/// would also accept Unicode letters per the XML `NameStartChar` production, +/// but `XPath` names in practice are ASCII. +fn is_name_start_char(b: u8) -> bool { + b.is_ascii_alphabetic() || b == b'_' +} + +/// Returns `true` if the byte is a valid name continuation character. +/// +/// Accepts letters, digits, `-`, `_`, and `.`. The hyphen is included +/// because `XPath` axis names like `descendant-or-self` use hyphens, and we +/// consume the full name before disambiguation. The dot is included for +/// names that contain dots, though it is not part of XML `NCName`. +fn is_name_char(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + /// Helper to tokenize and return the token vector, panicking on error. + fn tokenize(input: &str) -> Vec<Token> { + let mut lexer = Lexer::new(input); + lexer.tokenize().unwrap() + } + + #[test] + fn test_tokenize_simple_path() { + let tokens = tokenize("child::p"); + assert_eq!(tokens.len(), 3); + assert_eq!(tokens[0], Token::AxisName("child".to_string())); + assert_eq!(tokens[1], Token::ColonColon); + assert_eq!(tokens[2], Token::Name("p".to_string())); + } + + #[test] + fn test_tokenize_abbreviated_path() { + let tokens = tokenize("/html/body"); + assert_eq!( + tokens, + vec![ + Token::Slash, + Token::Name("html".to_string()), + Token::Slash, + Token::Name("body".to_string()), + ] + ); + } + + #[test] + fn test_tokenize_double_slash() { + let tokens = tokenize("//div"); + assert_eq!( + tokens, + vec![Token::DoubleSlash, Token::Name("div".to_string()),] + ); + } + + #[test] + fn test_tokenize_predicate() { + let tokens = tokenize("p[1]"); + assert_eq!( + tokens, + vec![ + Token::Name("p".to_string()), + Token::LeftBracket, + Token::Number(1.0), + Token::RightBracket, + ] + ); + } + + #[test] + fn test_tokenize_attribute_access() { + let tokens = tokenize("@class"); + assert_eq!(tokens, vec![Token::At, Token::Name("class".to_string()),]); + } + + #[test] + fn test_tokenize_function_call() { + let tokens = tokenize("contains(name, 'foo')"); + assert_eq!( + tokens, + vec![ + Token::FunctionName("contains".to_string()), + Token::LeftParen, + Token::Name("name".to_string()), + Token::Comma, + Token::Literal("foo".to_string()), + Token::RightParen, + ] + ); + } + + #[test] + fn test_tokenize_node_type() { + let tokens = tokenize("text()"); + assert_eq!( + tokens, + vec![ + Token::NodeType("text".to_string()), + Token::LeftParen, + Token::RightParen, + ] + ); + } + + #[test] + fn test_tokenize_all_node_types() { + for name in &["node", "text", "comment", "processing-instruction"] { + let input = format!("{name}()"); + let tokens = tokenize(&input); + assert_eq!(tokens[0], Token::NodeType((*name).to_string())); + } + } + + #[test] + fn test_tokenize_string_literals() { + let tokens = tokenize(r#""hello""#); + assert_eq!(tokens, vec![Token::Literal("hello".to_string())]); + + let tokens = tokenize("'world'"); + assert_eq!(tokens, vec![Token::Literal("world".to_string())]); + } + + #[test] + fn test_tokenize_number_literals() { + let tokens = tokenize("42"); + assert_eq!(tokens, vec![Token::Number(42.0)]); + + let tokens = tokenize("3.5"); + assert_eq!(tokens, vec![Token::Number(3.5)]); + + let tokens = tokenize(".5"); + assert_eq!(tokens, vec![Token::Number(0.5)]); + + let tokens = tokenize("0.0"); + assert_eq!(tokens, vec![Token::Number(0.0)]); + } + + #[test] + fn test_tokenize_variable_reference() { + let tokens = tokenize("$foo"); + assert_eq!(tokens, vec![Token::VariableReference("foo".to_string())]); + } + + #[test] + fn test_tokenize_comparison_operators() { + let tokens = tokenize("a = b"); + assert_eq!( + tokens, + vec![ + Token::Name("a".to_string()), + Token::Equal, + Token::Name("b".to_string()), + ] + ); + + let tokens = tokenize("a != b"); + assert_eq!( + tokens, + vec![ + Token::Name("a".to_string()), + Token::NotEqual, + Token::Name("b".to_string()), + ] + ); + + let tokens = tokenize("a < b"); + assert_eq!( + tokens, + vec![ + Token::Name("a".to_string()), + Token::LessThan, + Token::Name("b".to_string()), + ] + ); + + let tokens = tokenize("a <= b"); + assert_eq!( + tokens, + vec![ + Token::Name("a".to_string()), + Token::LessThanEqual, + Token::Name("b".to_string()), + ] + ); + } + + #[test] + fn test_tokenize_arithmetic() { + let tokens = tokenize("1 + 2"); + assert_eq!( + tokens, + vec![Token::Number(1.0), Token::Plus, Token::Number(2.0),] + ); + + let tokens = tokenize("a - b"); + assert_eq!( + tokens, + vec![ + Token::Name("a".to_string()), + Token::Minus, + Token::Name("b".to_string()), + ] + ); + } + + #[test] + fn test_tokenize_star_as_wildcard() { + // * at the start of an expression is a name test (wildcard), not multiply + let tokens = tokenize("*"); + assert_eq!(tokens, vec![Token::Name("*".to_string())]); + + // * after / is a name test + let tokens = tokenize("/*"); + assert_eq!(tokens, vec![Token::Slash, Token::Name("*".to_string())]); + } + + #[test] + fn test_tokenize_star_as_multiply() { + // * after a name (operand-ending) is multiply + let tokens = tokenize("a * b"); + assert_eq!( + tokens, + vec![ + Token::Name("a".to_string()), + Token::Star, + Token::Name("b".to_string()), + ] + ); + + // * after ) is multiply + let tokens = tokenize("count(x) * 2"); + assert_eq!( + tokens, + vec![ + Token::FunctionName("count".to_string()), + Token::LeftParen, + Token::Name("x".to_string()), + Token::RightParen, + Token::Star, + Token::Number(2.0), + ] + ); + } + + #[test] + fn test_tokenize_keyword_operators() { + // 'and' and 'or' after operand-ending tokens are operators + let tokens = tokenize("a and b"); + assert_eq!( + tokens, + vec![ + Token::Name("a".to_string()), + Token::And, + Token::Name("b".to_string()), + ] + ); + + let tokens = tokenize("a or b"); + assert_eq!( + tokens, + vec![ + Token::Name("a".to_string()), + Token::Or, + Token::Name("b".to_string()), + ] + ); + + let tokens = tokenize("a div b"); + assert_eq!( + tokens, + vec![ + Token::Name("a".to_string()), + Token::Div, + Token::Name("b".to_string()), + ] + ); + + let tokens = tokenize("a mod b"); + assert_eq!( + tokens, + vec![ + Token::Name("a".to_string()), + Token::Mod, + Token::Name("b".to_string()), + ] + ); + } + + #[test] + fn test_tokenize_dot_and_dotdot() { + let tokens = tokenize("./.. "); + assert_eq!(tokens, vec![Token::Dot, Token::Slash, Token::DotDot,]); + } + + #[test] + fn test_tokenize_complex_expression() { + let tokens = tokenize("//div[@class='main']/p[position() > 1]"); + assert_eq!( + tokens, + vec![ + Token::DoubleSlash, + Token::Name("div".to_string()), + Token::LeftBracket, + Token::At, + Token::Name("class".to_string()), + Token::Equal, + Token::Literal("main".to_string()), + Token::RightBracket, + Token::Slash, + Token::Name("p".to_string()), + Token::LeftBracket, + Token::FunctionName("position".to_string()), + Token::LeftParen, + Token::RightParen, + Token::GreaterThan, + Token::Number(1.0), + Token::RightBracket, + ] + ); + } + + #[test] + fn test_tokenize_union_operator() { + let tokens = tokenize("a | b"); + assert_eq!( + tokens, + vec![ + Token::Name("a".to_string()), + Token::Pipe, + Token::Name("b".to_string()), + ] + ); + } + + #[test] + fn test_tokenize_qname() { + let tokens = tokenize("svg:rect"); + assert_eq!(tokens, vec![Token::Name("svg:rect".to_string())]); + } + + #[test] + fn test_tokenize_axis_specifier() { + let tokens = tokenize("ancestor-or-self::node()"); + assert_eq!( + tokens, + vec![ + Token::AxisName("ancestor-or-self".to_string()), + Token::ColonColon, + Token::NodeType("node".to_string()), + Token::LeftParen, + Token::RightParen, + ] + ); + } + + #[test] + fn test_tokenize_empty_input() { + let tokens = tokenize(""); + assert!(tokens.is_empty()); + } + + #[test] + fn test_tokenize_whitespace_only() { + let tokens = tokenize(" \t\n "); + assert!(tokens.is_empty()); + } + + #[test] + fn test_tokenize_unterminated_string_error() { + let mut lexer = Lexer::new("\"unterminated"); + let result = lexer.tokenize(); + assert!(result.is_err()); + if let Err(err) = result { + assert!(err.message.contains("unterminated")); + } + } + + #[test] + fn test_tokenize_invalid_char_after_bang() { + let mut lexer = Lexer::new("!x"); + let result = lexer.tokenize(); + assert!(result.is_err()); + } + + #[test] + fn test_tokenize_variable_reference_qname() { + let tokens = tokenize("$ns:var"); + assert_eq!(tokens, vec![Token::VariableReference("ns:var".to_string())]); + } + + #[test] + fn test_xpath_error_display() { + let err = XPathError { + message: "test error".to_string(), + position: 5, + }; + assert_eq!(err.to_string(), "XPath error at position 5: test error"); + } + + #[test] + fn test_token_display() { + assert_eq!(Token::LeftParen.to_string(), "("); + assert_eq!(Token::RightParen.to_string(), ")"); + assert_eq!(Token::Slash.to_string(), "/"); + assert_eq!(Token::DoubleSlash.to_string(), "//"); + assert_eq!(Token::Star.to_string(), "*"); + assert_eq!(Token::Number(2.5).to_string(), "2.5"); + assert_eq!(Token::Literal("hi".to_string()).to_string(), "\"hi\""); + assert_eq!(Token::VariableReference("x".to_string()).to_string(), "$x"); + } + + #[test] + fn test_tokenize_multiple_predicates() { + let tokens = tokenize("p[1][@class]"); + assert_eq!( + tokens, + vec![ + Token::Name("p".to_string()), + Token::LeftBracket, + Token::Number(1.0), + Token::RightBracket, + Token::LeftBracket, + Token::At, + Token::Name("class".to_string()), + Token::RightBracket, + ] + ); + } + + #[test] + fn test_tokenize_nested_function_calls() { + let tokens = tokenize("concat(substring(a, 1), 'x')"); + assert_eq!( + tokens, + vec![ + Token::FunctionName("concat".to_string()), + Token::LeftParen, + Token::FunctionName("substring".to_string()), + Token::LeftParen, + Token::Name("a".to_string()), + Token::Comma, + Token::Number(1.0), + Token::RightParen, + Token::Comma, + Token::Literal("x".to_string()), + Token::RightParen, + ] + ); + } + + #[test] + fn test_tokenize_unary_minus() { + let tokens = tokenize("-5"); + assert_eq!(tokens, vec![Token::Minus, Token::Number(5.0),]); + } + + #[test] + fn test_tokenize_greater_than_equal() { + let tokens = tokenize("a >= 10"); + assert_eq!( + tokens, + vec![ + Token::Name("a".to_string()), + Token::GreaterThanEqual, + Token::Number(10.0), + ] + ); + } +} diff --git a/browser/vendor/xmloxide/src/xpath/mod.rs b/browser/vendor/xmloxide/src/xpath/mod.rs new file mode 100644 index 000000000..14c7eda00 --- /dev/null +++ b/browser/vendor/xmloxide/src/xpath/mod.rs @@ -0,0 +1,81 @@ +//! `XPath` 1.0 query language implementation. +//! +//! This module provides an implementation of the `XPath` 1.0 specification +//! (<https://www.w3.org/TR/xpath-10/>), including expression parsing and +//! evaluation against an XML document tree. +//! +//! # Quick Start +//! +//! ``` +//! use xmloxide::Document; +//! use xmloxide::xpath::{evaluate, XPathValue}; +//! +//! let doc = Document::parse_str("<root><a>1</a><b>2</b></root>").unwrap(); +//! let root = doc.root_element().unwrap(); +//! let result = evaluate(&doc, root, "count(*)").unwrap(); +//! assert_eq!(result.to_number(), 2.0); +//! ``` +//! +//! # Known Limitations +//! +//! - The `namespace::` axis returns the element's `NodeId` when in-scope +//! namespaces match. Namespace nodes are not materialized as separate +//! nodes (attribute nodes are — see [`types::XPathNode`]). +//! +//! # Submodules +//! +//! - [`ast`]: Abstract syntax tree types for parsed `XPath` expressions. +//! - [`lexer`]: Tokenizer for `XPath` expression strings. +//! - [`types`]: `XPath` value types and comparison helpers. +//! - [`parser`]: Recursive descent parser for `XPath` expressions. +//! - [`eval`]: Expression evaluator against a document tree. + +pub mod ast; +pub mod eval; +pub mod lexer; +pub mod parser; +pub(crate) mod regex; +pub mod types; + +pub use eval::XPathContext; +pub use types::{XPathError, XPathNode, XPathValue}; + +use crate::tree::{Document, NodeId}; + +/// Evaluates an `XPath` 1.0 expression against a document node. +/// +/// This is a convenience function that parses the expression and evaluates it +/// in a single call. For evaluating the same expression against multiple +/// context nodes, use [`parser::parse`] and [`XPathContext::evaluate`] +/// separately to avoid re-parsing. +/// +/// # Examples +/// +/// ``` +/// use xmloxide::Document; +/// use xmloxide::xpath::{evaluate, XPathValue}; +/// +/// let doc = Document::parse_str("<root><child>Hello</child></root>").unwrap(); +/// let root = doc.root_element().unwrap(); +/// +/// // Count child elements +/// let result = evaluate(&doc, root, "count(*)").unwrap(); +/// assert_eq!(result.to_number(), 1.0); +/// +/// // Get text content +/// let result = evaluate(&doc, root, "string(child)").unwrap(); +/// assert_eq!(result.to_xpath_string(), "Hello"); +/// ``` +/// +/// # Errors +/// +/// Returns [`XPathError`] if the expression is malformed or evaluation fails. +pub fn evaluate( + doc: &Document, + context_node: NodeId, + expression: &str, +) -> Result<XPathValue, XPathError> { + let expr = parser::parse(expression)?; + let ctx = XPathContext::new(doc, context_node); + ctx.evaluate(&expr) +} diff --git a/browser/vendor/xmloxide/src/xpath/parser.rs b/browser/vendor/xmloxide/src/xpath/parser.rs new file mode 100644 index 000000000..641b6ba37 --- /dev/null +++ b/browser/vendor/xmloxide/src/xpath/parser.rs @@ -0,0 +1,1500 @@ +//! `XPath` 1.0 expression parser. +//! +//! This module implements a recursive descent parser for `XPath` 1.0 expressions +//! as specified in <https://www.w3.org/TR/xpath-10/#section-Grammar>. The parser +//! consumes a `Vec<Token>` (produced by the [`super::lexer::Lexer`]) and +//! produces an [`Expr`] AST. +//! +//! # Operator Precedence +//! +//! From lowest to highest: +//! 1. `or` +//! 2. `and` +//! 3. `=`, `!=` (equality) +//! 4. `<`, `<=`, `>`, `>=` (relational) +//! 5. `+`, `-` (additive) +//! 6. `*`, `div`, `mod` (multiplicative) +//! 7. Unary `-` +//! 8. `|` (union) +//! 9. Filter expressions (primary expression with predicates) +//! 10. Path expressions (location paths) +//! +//! # Grammar Productions +//! +//! The parser follows the `XPath` 1.0 grammar closely, with each grammar +//! production implemented as a method on the internal `Parser` struct. + +use super::ast::{Axis, BinaryOp, Expr, NodeTest, Step}; +use super::lexer::{Lexer, Token, XPathError}; + +/// Parses an `XPath` expression string into an AST. +/// +/// This function tokenizes the input using the [`Lexer`] and then parses the +/// token stream into an [`Expr`] AST using a recursive descent parser. +/// +/// # Errors +/// +/// Returns [`XPathError`] if the input is not a valid `XPath` 1.0 expression. +/// The error includes a human-readable message and the byte offset where the +/// error was detected. +/// +/// # Examples +/// +/// ```ignore +/// use xmloxide::xpath::parser::parse; +/// +/// let expr = parse("/html/body/p").unwrap(); +/// let expr = parse("//book[@price > 10.00]").unwrap(); +/// ``` +pub fn parse(input: &str) -> Result<Expr, XPathError> { + let mut lexer = Lexer::new(input); + let tokens = lexer.tokenize()?; + + if tokens.is_empty() { + return Err(XPathError { + message: "empty XPath expression".to_string(), + position: 0, + }); + } + + let mut parser = Parser::new(tokens); + let expr = parser.parse_expr()?; + + if parser.pos < parser.tokens.len() { + return Err(parser.error(&format!( + "unexpected token '{}' after expression", + parser.tokens[parser.pos] + ))); + } + + Ok(expr) +} + +/// Internal recursive descent parser for `XPath` 1.0 token streams. +struct Parser { + /// The token stream produced by the lexer. + tokens: Vec<Token>, + /// Current position in the token stream. + pos: usize, +} + +impl Parser { + /// Creates a new parser for the given token stream. + fn new(tokens: Vec<Token>) -> Self { + Self { tokens, pos: 0 } + } + + // ----------------------------------------------------------------------- + // Token access helpers + // ----------------------------------------------------------------------- + + /// Returns a reference to the current token, or `None` if at end. + fn peek(&self) -> Option<&Token> { + self.tokens.get(self.pos) + } + + /// Returns `true` if the current token matches the given token. + fn check(&self, token: &Token) -> bool { + self.peek() == Some(token) + } + + /// Consumes the current token if it matches `token`, returning `true`. + /// Returns `false` without consuming if it does not match. + fn eat(&mut self, token: &Token) -> bool { + if self.check(token) { + self.pos += 1; + true + } else { + false + } + } + + /// Consumes the current token if it matches `token`, or returns an error. + fn expect(&mut self, token: &Token) -> Result<(), XPathError> { + if self.eat(token) { + Ok(()) + } else { + Err(self.error(&format!( + "expected '{}', found {}", + token, + self.describe_current() + ))) + } + } + + /// Advances the parser by one token and returns the consumed token. + fn advance(&mut self) -> Option<Token> { + if self.pos < self.tokens.len() { + let token = self.tokens[self.pos].clone(); + self.pos += 1; + Some(token) + } else { + None + } + } + + /// Returns a human-readable description of the current token (for errors). + fn describe_current(&self) -> String { + self.peek() + .map_or_else(|| "end of expression".to_string(), |t| format!("'{t}'")) + } + + /// Creates an error at the current position. + fn error(&self, message: &str) -> XPathError { + XPathError { + message: message.to_string(), + position: self.pos, + } + } + + // ----------------------------------------------------------------------- + // Grammar productions + // ----------------------------------------------------------------------- + + /// Parses an `XPath` expression. + /// + /// ```text + /// Expr ::= OrExpr + /// ``` + /// See `XPath` 1.0 section 3. + fn parse_expr(&mut self) -> Result<Expr, XPathError> { + self.parse_or_expr() + } + + /// Parses an `or` expression. + /// + /// ```text + /// OrExpr ::= AndExpr ('or' AndExpr)* + /// ``` + /// See `XPath` 1.0 section 3.4. + fn parse_or_expr(&mut self) -> Result<Expr, XPathError> { + let mut left = self.parse_and_expr()?; + while self.eat(&Token::Or) { + let right = self.parse_and_expr()?; + left = Expr::BinaryOp { + op: BinaryOp::Or, + left: Box::new(left), + right: Box::new(right), + }; + } + Ok(left) + } + + /// Parses an `and` expression. + /// + /// ```text + /// AndExpr ::= EqualityExpr ('and' EqualityExpr)* + /// ``` + /// See `XPath` 1.0 section 3.4. + fn parse_and_expr(&mut self) -> Result<Expr, XPathError> { + let mut left = self.parse_equality_expr()?; + while self.eat(&Token::And) { + let right = self.parse_equality_expr()?; + left = Expr::BinaryOp { + op: BinaryOp::And, + left: Box::new(left), + right: Box::new(right), + }; + } + Ok(left) + } + + /// Parses an equality expression. + /// + /// ```text + /// EqualityExpr ::= RelationalExpr (('=' | '!=') RelationalExpr)* + /// ``` + /// See `XPath` 1.0 section 3.4. + fn parse_equality_expr(&mut self) -> Result<Expr, XPathError> { + let mut left = self.parse_relational_expr()?; + loop { + if self.eat(&Token::Equal) { + let right = self.parse_relational_expr()?; + left = Expr::BinaryOp { + op: BinaryOp::Eq, + left: Box::new(left), + right: Box::new(right), + }; + } else if self.eat(&Token::NotEqual) { + let right = self.parse_relational_expr()?; + left = Expr::BinaryOp { + op: BinaryOp::Neq, + left: Box::new(left), + right: Box::new(right), + }; + } else { + break; + } + } + Ok(left) + } + + /// Parses a relational expression. + /// + /// ```text + /// RelationalExpr ::= AdditiveExpr (('<' | '<=' | '>' | '>=') AdditiveExpr)* + /// ``` + /// See `XPath` 1.0 section 3.4. + fn parse_relational_expr(&mut self) -> Result<Expr, XPathError> { + let mut left = self.parse_additive_expr()?; + loop { + let op = if self.eat(&Token::LessThan) { + Some(BinaryOp::Lt) + } else if self.eat(&Token::LessThanEqual) { + Some(BinaryOp::Lte) + } else if self.eat(&Token::GreaterThan) { + Some(BinaryOp::Gt) + } else if self.eat(&Token::GreaterThanEqual) { + Some(BinaryOp::Gte) + } else { + None + }; + if let Some(op) = op { + let right = self.parse_additive_expr()?; + left = Expr::BinaryOp { + op, + left: Box::new(left), + right: Box::new(right), + }; + } else { + break; + } + } + Ok(left) + } + + /// Parses an additive expression. + /// + /// ```text + /// AdditiveExpr ::= MultiplicativeExpr (('+' | '-') MultiplicativeExpr)* + /// ``` + /// See `XPath` 1.0 section 3.5. + fn parse_additive_expr(&mut self) -> Result<Expr, XPathError> { + let mut left = self.parse_multiplicative_expr()?; + loop { + if self.eat(&Token::Plus) { + let right = self.parse_multiplicative_expr()?; + left = Expr::BinaryOp { + op: BinaryOp::Add, + left: Box::new(left), + right: Box::new(right), + }; + } else if self.eat(&Token::Minus) { + let right = self.parse_multiplicative_expr()?; + left = Expr::BinaryOp { + op: BinaryOp::Sub, + left: Box::new(left), + right: Box::new(right), + }; + } else { + break; + } + } + Ok(left) + } + + /// Parses a multiplicative expression. + /// + /// ```text + /// MultiplicativeExpr ::= UnaryExpr (('*' | 'div' | 'mod') UnaryExpr)* + /// ``` + /// See `XPath` 1.0 section 3.5. + fn parse_multiplicative_expr(&mut self) -> Result<Expr, XPathError> { + let mut left = self.parse_unary_expr()?; + loop { + if self.eat(&Token::Star) { + let right = self.parse_unary_expr()?; + left = Expr::BinaryOp { + op: BinaryOp::Mul, + left: Box::new(left), + right: Box::new(right), + }; + } else if self.eat(&Token::Div) { + let right = self.parse_unary_expr()?; + left = Expr::BinaryOp { + op: BinaryOp::Div, + left: Box::new(left), + right: Box::new(right), + }; + } else if self.eat(&Token::Mod) { + let right = self.parse_unary_expr()?; + left = Expr::BinaryOp { + op: BinaryOp::Mod, + left: Box::new(left), + right: Box::new(right), + }; + } else { + break; + } + } + Ok(left) + } + + /// Parses a unary expression. + /// + /// ```text + /// UnaryExpr ::= '-'* UnionExpr + /// ``` + /// See `XPath` 1.0 section 3.5. + fn parse_unary_expr(&mut self) -> Result<Expr, XPathError> { + if self.eat(&Token::Minus) { + let inner = self.parse_unary_expr()?; + Ok(Expr::UnaryNeg(Box::new(inner))) + } else { + self.parse_union_expr() + } + } + + /// Parses a union expression. + /// + /// ```text + /// UnionExpr ::= PathExpr ('|' PathExpr)* + /// ``` + /// See `XPath` 1.0 section 3.3. + fn parse_union_expr(&mut self) -> Result<Expr, XPathError> { + let mut left = self.parse_path_expr()?; + while self.eat(&Token::Pipe) { + let right = self.parse_path_expr()?; + left = Expr::Union(Box::new(left), Box::new(right)); + } + Ok(left) + } + + /// Parses a path expression. + /// + /// ```text + /// PathExpr ::= LocationPath + /// | FilterExpr + /// | FilterExpr '/' RelativeLocationPath + /// | FilterExpr '//' RelativeLocationPath + /// ``` + /// + /// The tricky part is distinguishing location paths from filter expressions. + /// A location path starts with `/`, `//`, `.`, `..`, `@`, an axis name, a + /// node type, or a name test. A filter expression starts with a primary + /// expression (variable, literal, number, function call, or parenthesized + /// expression). + /// + /// See `XPath` 1.0 section 3.3. + fn parse_path_expr(&mut self) -> Result<Expr, XPathError> { + match self.peek() { + // Tokens that start a location path: `/`, `//`, `.`, `..`, `@`, + // axis names, node types, or name tests. + Some( + Token::Slash + | Token::DoubleSlash + | Token::Dot + | Token::DotDot + | Token::At + | Token::AxisName(_) + | Token::NodeType(_) + | Token::Name(_), + ) => self.parse_location_path(), + + // Primary expressions: variable, literal, number, function call, + // or parenthesized expression. These start filter expressions. + Some( + Token::VariableReference(_) + | Token::Literal(_) + | Token::Number(_) + | Token::LeftParen + | Token::FunctionName(_), + ) => { + let expr = self.parse_filter_expr()?; + + // A filter expression can be followed by '/' or '//' to + // extend it with a relative location path. + if self.check(&Token::Slash) || self.check(&Token::DoubleSlash) { + self.parse_filter_path_continuation(expr) + } else { + Ok(expr) + } + } + + _ => Err(self.error(&format!( + "expected expression, found {}", + self.describe_current() + ))), + } + } + + /// Parses the `'/' relative_path` or `'//' relative_path` continuation + /// after a filter expression. + /// + /// Produces an [`Expr::FilterPath`] holding the filter expression and + /// the continuation steps; the evaluator applies the steps to the + /// filter's node-set result. See `XPath` 1.0 section 3.3 (`PathExpr`). + fn parse_filter_path_continuation(&mut self, filter: Expr) -> Result<Expr, XPathError> { + let mut steps = Vec::new(); + + if self.eat(&Token::DoubleSlash) { + // '//' is shorthand for /descendant-or-self::node()/ + steps.push(Step { + axis: Axis::DescendantOrSelf, + node_test: NodeTest::Node, + predicates: Vec::new(), + }); + } else { + self.expect(&Token::Slash)?; + } + + // Parse the relative location path + self.parse_relative_location_path_into(&mut steps)?; + + Ok(Expr::FilterPath { + expr: Box::new(filter), + steps, + }) + } + + /// Parses a filter expression. + /// + /// ```text + /// FilterExpr ::= PrimaryExpr Predicate* + /// ``` + /// See `XPath` 1.0 section 3.3. + fn parse_filter_expr(&mut self) -> Result<Expr, XPathError> { + let expr = self.parse_primary_expr()?; + let predicates = self.parse_predicates()?; + + if predicates.is_empty() { + Ok(expr) + } else { + Ok(Expr::Filter { + expr: Box::new(expr), + predicates, + }) + } + } + + /// Parses a primary expression. + /// + /// ```text + /// PrimaryExpr ::= VariableReference + /// | '(' Expr ')' + /// | Literal + /// | Number + /// | FunctionCall + /// ``` + /// See `XPath` 1.0 section 3.5. + fn parse_primary_expr(&mut self) -> Result<Expr, XPathError> { + match self.peek().cloned() { + Some(Token::VariableReference(name)) => { + self.pos += 1; + Ok(Expr::Variable(name)) + } + Some(Token::Literal(value)) => { + self.pos += 1; + Ok(Expr::String(value)) + } + Some(Token::Number(value)) => { + self.pos += 1; + Ok(Expr::Number(value)) + } + Some(Token::LeftParen) => { + self.pos += 1; // consume '(' + let expr = self.parse_expr()?; + self.expect(&Token::RightParen)?; + Ok(expr) + } + Some(Token::FunctionName(_)) => self.parse_function_call(), + _ => Err(self.error(&format!( + "expected primary expression, found {}", + self.describe_current() + ))), + } + } + + /// Parses a function call. + /// + /// ```text + /// FunctionCall ::= FunctionName '(' (Argument (',' Argument)*)? ')' + /// Argument ::= Expr + /// ``` + /// See `XPath` 1.0 section 3.2. + fn parse_function_call(&mut self) -> Result<Expr, XPathError> { + let Some(Token::FunctionName(name)) = self.advance() else { + return Err(self.error("expected function name")); + }; + self.expect(&Token::LeftParen)?; + + let mut args = Vec::new(); + if !self.check(&Token::RightParen) { + args.push(self.parse_expr()?); + while self.eat(&Token::Comma) { + args.push(self.parse_expr()?); + } + } + + self.expect(&Token::RightParen)?; + + Ok(Expr::FunctionCall { name, args }) + } + + /// Parses a location path. + /// + /// ```text + /// LocationPath ::= RelativeLocationPath + /// | AbsoluteLocationPath + /// AbsoluteLocationPath ::= '/' RelativeLocationPath? + /// | AbbreviatedAbsoluteLocationPath + /// AbbreviatedAbsoluteLocationPath ::= '//' RelativeLocationPath + /// ``` + /// See `XPath` 1.0 section 2. + fn parse_location_path(&mut self) -> Result<Expr, XPathError> { + if self.check(&Token::Slash) { + self.pos += 1; // consume '/' + let mut steps = Vec::new(); + + // Check if there's a relative location path after '/' + if self.is_step_start() { + self.parse_relative_location_path_into(&mut steps)?; + } + + Ok(Expr::RootPath { steps }) + } else if self.eat(&Token::DoubleSlash) { + // '//' is shorthand for '/descendant-or-self::node()/' + let mut steps = vec![Step { + axis: Axis::DescendantOrSelf, + node_test: NodeTest::Node, + predicates: Vec::new(), + }]; + self.parse_relative_location_path_into(&mut steps)?; + Ok(Expr::RootPath { steps }) + } else { + // Relative location path + let mut steps = Vec::new(); + self.parse_relative_location_path_into(&mut steps)?; + Ok(Expr::Path { steps }) + } + } + + /// Parses a relative location path, appending steps to the provided vector. + /// + /// ```text + /// RelativeLocationPath ::= Step + /// | RelativeLocationPath '/' Step + /// | AbbreviatedRelativeLocationPath + /// AbbreviatedRelativeLocationPath ::= RelativeLocationPath '//' Step + /// ``` + /// See `XPath` 1.0 section 2. + fn parse_relative_location_path_into( + &mut self, + steps: &mut Vec<Step>, + ) -> Result<(), XPathError> { + steps.push(self.parse_step()?); + + loop { + if self.eat(&Token::DoubleSlash) { + // '//' inserts a descendant-or-self::node() step + steps.push(Step { + axis: Axis::DescendantOrSelf, + node_test: NodeTest::Node, + predicates: Vec::new(), + }); + steps.push(self.parse_step()?); + } else if self.eat(&Token::Slash) { + // Only continue if the next token can start a step + if self.is_step_start() { + steps.push(self.parse_step()?); + } else { + // Trailing slash with no step -- put it back + self.pos -= 1; + break; + } + } else { + break; + } + } + + Ok(()) + } + + /// Returns `true` if the current token can begin a location step. + fn is_step_start(&self) -> bool { + matches!( + self.peek(), + Some( + Token::Dot + | Token::DotDot + | Token::At + | Token::Name(_) + | Token::NodeType(_) + | Token::AxisName(_) + ) + ) + } + + /// Parses a single step in a location path. + /// + /// ```text + /// Step ::= AxisSpecifier NodeTest Predicate* + /// | AbbreviatedStep + /// AbbreviatedStep ::= '.' | '..' + /// ``` + /// See `XPath` 1.0 section 2.1. + fn parse_step(&mut self) -> Result<Step, XPathError> { + // Handle abbreviated steps + if self.eat(&Token::Dot) { + // '.' is shorthand for self::node() + return Ok(Step { + axis: Axis::Self_, + node_test: NodeTest::Node, + predicates: Vec::new(), + }); + } + if self.eat(&Token::DotDot) { + // '..' is shorthand for parent::node() + return Ok(Step { + axis: Axis::Parent, + node_test: NodeTest::Node, + predicates: Vec::new(), + }); + } + + // Parse axis specifier + let axis = self.parse_axis_specifier(); + + // Parse node test + let node_test = self.parse_node_test()?; + + // Parse predicates + let predicates = self.parse_predicates()?; + + Ok(Step { + axis, + node_test, + predicates, + }) + } + + /// Parses an axis specifier. + /// + /// ```text + /// AxisSpecifier ::= AxisName '::' + /// | AbbreviatedAxisSpecifier + /// AbbreviatedAxisSpecifier ::= '@'? + /// ``` + /// See `XPath` 1.0 section 2.2. + fn parse_axis_specifier(&mut self) -> Axis { + if self.eat(&Token::At) { + // '@' is shorthand for attribute:: + return Axis::Attribute; + } + + // Check for AxisName '::' + if let Some(Token::AxisName(name)) = self.peek().cloned() { + if self.tokens.get(self.pos + 1) == Some(&Token::ColonColon) { + if let Some(axis) = Axis::parse(&name) { + self.pos += 2; // consume axis name and '::' + return axis; + } + } + } + + // Default axis is child + Axis::Child + } + + /// Parses a node test. + /// + /// ```text + /// NodeTest ::= NameTest + /// | NodeType '(' ')' + /// | 'processing-instruction' '(' Literal ')' + /// NameTest ::= '*' + /// | NCName ':' '*' + /// | QName + /// ``` + /// See `XPath` 1.0 section 2.3. + fn parse_node_test(&mut self) -> Result<NodeTest, XPathError> { + match self.peek().cloned() { + Some(Token::NodeType(name)) => { + self.pos += 1; // consume node type name + self.expect(&Token::LeftParen)?; + + let node_test = match name.as_str() { + "node" => { + self.expect(&Token::RightParen)?; + NodeTest::Node + } + "text" => { + self.expect(&Token::RightParen)?; + NodeTest::Text + } + "comment" => { + self.expect(&Token::RightParen)?; + NodeTest::Comment + } + "processing-instruction" => { + // Optional literal argument + if let Some(Token::Literal(target)) = self.peek().cloned() { + self.pos += 1; + self.expect(&Token::RightParen)?; + NodeTest::ProcessingInstruction(Some(target)) + } else { + self.expect(&Token::RightParen)?; + NodeTest::ProcessingInstruction(None) + } + } + _ => { + return Err(self.error(&format!("unknown node type: {name}"))); + } + }; + + Ok(node_test) + } + Some(Token::Name(name)) => { + self.pos += 1; + if name == "*" { + Ok(NodeTest::Wildcard) + } else if let Some(prefix) = name.strip_suffix(":*") { + Ok(NodeTest::PrefixWildcard(prefix.to_string())) + } else { + Ok(NodeTest::Name(name)) + } + } + _ => Err(self.error(&format!( + "expected node test, found {}", + self.describe_current() + ))), + } + } + + /// Parses zero or more predicates. + /// + /// ```text + /// Predicate ::= '[' PredicateExpr ']' + /// PredicateExpr ::= Expr + /// ``` + /// See `XPath` 1.0 section 2.4. + fn parse_predicates(&mut self) -> Result<Vec<Expr>, XPathError> { + let mut predicates = Vec::new(); + while self.check(&Token::LeftBracket) { + self.pos += 1; // consume '[' + let expr = self.parse_expr()?; + self.expect(&Token::RightBracket)?; + predicates.push(expr); + } + Ok(predicates) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + // ----------------------------------------------------------------------- + // Helper for concise test assertions + // ----------------------------------------------------------------------- + + /// Parses the input and returns the AST, panicking on error. + fn p(input: &str) -> Expr { + parse(input).unwrap() + } + + /// Asserts that parsing the input fails. + fn assert_parse_error(input: &str) { + assert!(parse(input).is_err(), "expected parse error for: {input}"); + } + + // ----------------------------------------------------------------------- + // Simple paths + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_root_only() { + // `/` alone is a valid XPath selecting the root node + let expr = p("/"); + match expr { + Expr::RootPath { ref steps } => assert!(steps.is_empty()), + _ => panic!("expected RootPath, got: {expr:?}"), + } + } + + #[test] + fn test_parse_absolute_path_single_step() { + // `/root` + let expr = p("/root"); + match expr { + Expr::RootPath { ref steps } => { + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].axis, Axis::Child); + assert_eq!(steps[0].node_test, NodeTest::Name("root".to_string())); + } + _ => panic!("expected RootPath, got: {expr:?}"), + } + } + + #[test] + fn test_parse_relative_path() { + // `root/child` + let expr = p("root/child"); + match expr { + Expr::Path { ref steps } => { + assert_eq!(steps.len(), 2); + assert_eq!(steps[0].node_test, NodeTest::Name("root".to_string())); + assert_eq!(steps[1].node_test, NodeTest::Name("child".to_string())); + } + _ => panic!("expected Path, got: {expr:?}"), + } + } + + #[test] + fn test_parse_double_slash_path() { + // `//child` + let expr = p("//child"); + match expr { + Expr::RootPath { ref steps } => { + assert_eq!(steps.len(), 2); + // First step is the implicit descendant-or-self::node() + assert_eq!(steps[0].axis, Axis::DescendantOrSelf); + assert_eq!(steps[0].node_test, NodeTest::Node); + // Second step is child::child + assert_eq!(steps[1].axis, Axis::Child); + assert_eq!(steps[1].node_test, NodeTest::Name("child".to_string())); + } + _ => panic!("expected RootPath, got: {expr:?}"), + } + } + + // ----------------------------------------------------------------------- + // Abbreviated syntax + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_dot() { + // `.` is self::node() + let expr = p("."); + match expr { + Expr::Path { ref steps } => { + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].axis, Axis::Self_); + assert_eq!(steps[0].node_test, NodeTest::Node); + } + _ => panic!("expected Path, got: {expr:?}"), + } + } + + #[test] + fn test_parse_dotdot() { + // `..` is parent::node() + let expr = p(".."); + match expr { + Expr::Path { ref steps } => { + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].axis, Axis::Parent); + assert_eq!(steps[0].node_test, NodeTest::Node); + } + _ => panic!("expected Path, got: {expr:?}"), + } + } + + #[test] + fn test_parse_at_attribute() { + // `@attr` is attribute::attr + let expr = p("@attr"); + match expr { + Expr::Path { ref steps } => { + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].axis, Axis::Attribute); + assert_eq!(steps[0].node_test, NodeTest::Name("attr".to_string())); + } + _ => panic!("expected Path, got: {expr:?}"), + } + } + + #[test] + fn test_parse_dot_double_slash_child() { + // `.//child` is self::node() / descendant-or-self::node() / child::child + let expr = p(".//child"); + match expr { + Expr::Path { ref steps } => { + assert_eq!(steps.len(), 3); + assert_eq!(steps[0].axis, Axis::Self_); + assert_eq!(steps[0].node_test, NodeTest::Node); + assert_eq!(steps[1].axis, Axis::DescendantOrSelf); + assert_eq!(steps[1].node_test, NodeTest::Node); + assert_eq!(steps[2].axis, Axis::Child); + assert_eq!(steps[2].node_test, NodeTest::Name("child".to_string())); + } + _ => panic!("expected Path, got: {expr:?}"), + } + } + + // ----------------------------------------------------------------------- + // Predicates + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_predicate_numeric() { + // `child[1]` + let expr = p("child[1]"); + match expr { + Expr::Path { ref steps } => { + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].node_test, NodeTest::Name("child".to_string())); + assert_eq!(steps[0].predicates.len(), 1); + match &steps[0].predicates[0] { + Expr::Number(n) => assert!((n - 1.0).abs() < f64::EPSILON), + other => panic!("expected Number predicate, got: {other:?}"), + } + } + _ => panic!("expected Path, got: {expr:?}"), + } + } + + #[test] + fn test_parse_predicate_attribute_eq() { + // `child[@id='x']` + let expr = p("child[@id='x']"); + match expr { + Expr::Path { ref steps } => { + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].predicates.len(), 1); + match &steps[0].predicates[0] { + Expr::BinaryOp { op, left, right } => { + assert_eq!(*op, BinaryOp::Eq); + // LHS should be @id (attribute::id path) + match left.as_ref() { + Expr::Path { steps } => { + assert_eq!(steps[0].axis, Axis::Attribute); + assert_eq!(steps[0].node_test, NodeTest::Name("id".to_string())); + } + other => panic!("expected Path for @id, got: {other:?}"), + } + // RHS should be 'x' + match right.as_ref() { + Expr::String(s) => assert_eq!(s, "x"), + other => panic!("expected String 'x', got: {other:?}"), + } + } + other => panic!("expected BinaryOp predicate, got: {other:?}"), + } + } + _ => panic!("expected Path, got: {expr:?}"), + } + } + + #[test] + fn test_parse_predicate_function_call() { + // `child[position()=1]` + let expr = p("child[position()=1]"); + match expr { + Expr::Path { ref steps } => { + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].predicates.len(), 1); + match &steps[0].predicates[0] { + Expr::BinaryOp { op, left, .. } => { + assert_eq!(*op, BinaryOp::Eq); + match left.as_ref() { + Expr::FunctionCall { name, args } => { + assert_eq!(name, "position"); + assert!(args.is_empty()); + } + other => panic!("expected FunctionCall, got: {other:?}"), + } + } + other => panic!("expected BinaryOp, got: {other:?}"), + } + } + _ => panic!("expected Path, got: {expr:?}"), + } + } + + // ----------------------------------------------------------------------- + // Operators + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_addition() { + // `1 + 2` + let expr = p("1 + 2"); + match expr { + Expr::BinaryOp { op, left, right } => { + assert_eq!(op, BinaryOp::Add); + match (*left, *right) { + (Expr::Number(l), Expr::Number(r)) => { + assert!((l - 1.0).abs() < f64::EPSILON); + assert!((r - 2.0).abs() < f64::EPSILON); + } + (l, r) => panic!("expected Number operands, got: {l:?}, {r:?}"), + } + } + _ => panic!("expected BinaryOp, got: {expr:?}"), + } + } + + #[test] + fn test_parse_equality() { + // `a = b` + let expr = p("a = b"); + match expr { + Expr::BinaryOp { op, .. } => assert_eq!(op, BinaryOp::Eq), + _ => panic!("expected BinaryOp Eq, got: {expr:?}"), + } + } + + #[test] + fn test_parse_and() { + // `a and b` + let expr = p("a and b"); + match expr { + Expr::BinaryOp { op, .. } => assert_eq!(op, BinaryOp::And), + _ => panic!("expected BinaryOp And, got: {expr:?}"), + } + } + + #[test] + fn test_parse_or() { + // `a or b` + let expr = p("a or b"); + match expr { + Expr::BinaryOp { op, .. } => assert_eq!(op, BinaryOp::Or), + _ => panic!("expected BinaryOp Or, got: {expr:?}"), + } + } + + // ----------------------------------------------------------------------- + // Function calls + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_function_call_count() { + // `count(//node)` + let expr = p("count(//node)"); + match expr { + Expr::FunctionCall { name, args } => { + assert_eq!(name, "count"); + assert_eq!(args.len(), 1); + } + _ => panic!("expected FunctionCall, got: {expr:?}"), + } + } + + #[test] + fn test_parse_function_call_string_length() { + // `string-length('hello')` + let expr = p("string-length('hello')"); + match expr { + Expr::FunctionCall { name, args } => { + assert_eq!(name, "string-length"); + assert_eq!(args.len(), 1); + match &args[0] { + Expr::String(s) => assert_eq!(s, "hello"), + other => panic!("expected String arg, got: {other:?}"), + } + } + _ => panic!("expected FunctionCall, got: {expr:?}"), + } + } + + #[test] + fn test_parse_function_call_concat() { + // `concat('a', 'b', 'c')` + let expr = p("concat('a', 'b', 'c')"); + match expr { + Expr::FunctionCall { name, args } => { + assert_eq!(name, "concat"); + assert_eq!(args.len(), 3); + for (i, expected) in ["a", "b", "c"].iter().enumerate() { + match &args[i] { + Expr::String(s) => assert_eq!(s, *expected), + other => panic!("expected String arg, got: {other:?}"), + } + } + } + _ => panic!("expected FunctionCall, got: {expr:?}"), + } + } + + // ----------------------------------------------------------------------- + // Complex expressions + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_complex_predicate_with_comparison() { + // `//book[@price > 10.00]` + let expr = p("//book[@price > 10.00]"); + match expr { + Expr::RootPath { ref steps } => { + // descendant-or-self::node(), child::book[@price > 10] + assert_eq!(steps.len(), 2); + assert_eq!(steps[1].node_test, NodeTest::Name("book".to_string())); + assert_eq!(steps[1].predicates.len(), 1); + match &steps[1].predicates[0] { + Expr::BinaryOp { op, .. } => assert_eq!(*op, BinaryOp::Gt), + other => panic!("expected BinaryOp Gt, got: {other:?}"), + } + } + _ => panic!("expected RootPath, got: {expr:?}"), + } + } + + #[test] + fn test_parse_complex_path_with_last() { + // `/root/child[last()]/text()` + let expr = p("/root/child[last()]/text()"); + match expr { + Expr::RootPath { ref steps } => { + assert_eq!(steps.len(), 3); + assert_eq!(steps[0].node_test, NodeTest::Name("root".to_string())); + assert_eq!(steps[1].node_test, NodeTest::Name("child".to_string())); + assert_eq!(steps[1].predicates.len(), 1); + match &steps[1].predicates[0] { + Expr::FunctionCall { name, args } => { + assert_eq!(name, "last"); + assert!(args.is_empty()); + } + other => panic!("expected FunctionCall last(), got: {other:?}"), + } + assert_eq!(steps[2].node_test, NodeTest::Text); + } + _ => panic!("expected RootPath, got: {expr:?}"), + } + } + + // ----------------------------------------------------------------------- + // Union + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_union() { + // `a | b` + let expr = p("a | b"); + match expr { + Expr::Union(left, right) => { + match *left { + Expr::Path { ref steps } => { + assert_eq!(steps[0].node_test, NodeTest::Name("a".to_string())); + } + _ => panic!("expected Path for left union operand"), + } + match *right { + Expr::Path { ref steps } => { + assert_eq!(steps[0].node_test, NodeTest::Name("b".to_string())); + } + _ => panic!("expected Path for right union operand"), + } + } + _ => panic!("expected Union, got: {expr:?}"), + } + } + + // ----------------------------------------------------------------------- + // Variables + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_variable_plus_number() { + // `$x + 1` + let expr = p("$x + 1"); + match expr { + Expr::BinaryOp { op, left, right } => { + assert_eq!(op, BinaryOp::Add); + match *left { + Expr::Variable(ref name) => assert_eq!(name, "x"), + _ => panic!("expected Variable"), + } + match *right { + Expr::Number(n) => assert!((n - 1.0).abs() < f64::EPSILON), + _ => panic!("expected Number"), + } + } + _ => panic!("expected BinaryOp, got: {expr:?}"), + } + } + + // ----------------------------------------------------------------------- + // Nested/parenthesized expressions + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_parenthesized_expr() { + // `(1 + 2) * 3` + let expr = p("(1 + 2) * 3"); + match expr { + Expr::BinaryOp { op, left, right } => { + assert_eq!(op, BinaryOp::Mul); + match *left { + Expr::BinaryOp { op, .. } => assert_eq!(op, BinaryOp::Add), + _ => panic!("expected inner BinaryOp Add"), + } + match *right { + Expr::Number(n) => assert!((n - 3.0).abs() < f64::EPSILON), + _ => panic!("expected Number 3"), + } + } + _ => panic!("expected BinaryOp, got: {expr:?}"), + } + } + + // ----------------------------------------------------------------------- + // Processing instructions + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_processing_instruction_with_target() { + // `processing-instruction('xml-stylesheet')` + let expr = p("processing-instruction('xml-stylesheet')"); + match expr { + Expr::Path { ref steps } => { + assert_eq!(steps.len(), 1); + assert_eq!( + steps[0].node_test, + NodeTest::ProcessingInstruction(Some("xml-stylesheet".to_string())) + ); + } + _ => panic!("expected Path, got: {expr:?}"), + } + } + + #[test] + fn test_parse_processing_instruction_no_target() { + // `processing-instruction()` + let expr = p("processing-instruction()"); + match expr { + Expr::Path { ref steps } => { + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].node_test, NodeTest::ProcessingInstruction(None)); + } + _ => panic!("expected Path, got: {expr:?}"), + } + } + + // ----------------------------------------------------------------------- + // Wildcards + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_wildcard() { + // `*` matches any element + let expr = p("*"); + match expr { + Expr::Path { ref steps } => { + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].node_test, NodeTest::Wildcard); + } + _ => panic!("expected Path, got: {expr:?}"), + } + } + + // ----------------------------------------------------------------------- + // Multiple predicates + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_multiple_predicates() { + // `child[1][@type='x']` + let expr = p("child[1][@type='x']"); + match expr { + Expr::Path { ref steps } => { + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].predicates.len(), 2); + match &steps[0].predicates[0] { + Expr::Number(n) => assert!((n - 1.0).abs() < f64::EPSILON), + other => panic!("expected Number predicate, got: {other:?}"), + } + match &steps[0].predicates[1] { + Expr::BinaryOp { op, .. } => assert_eq!(*op, BinaryOp::Eq), + other => panic!("expected BinaryOp Eq predicate, got: {other:?}"), + } + } + _ => panic!("expected Path, got: {expr:?}"), + } + } + + // ----------------------------------------------------------------------- + // Operator precedence + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_operator_precedence() { + // `1 + 2 * 3` should be `1 + (2 * 3)` due to precedence + let expr = p("1 + 2 * 3"); + match expr { + Expr::BinaryOp { op, left, right } => { + assert_eq!(op, BinaryOp::Add); + match *left { + Expr::Number(n) => assert!((n - 1.0).abs() < f64::EPSILON), + _ => panic!("expected Number 1"), + } + match *right { + Expr::BinaryOp { op, .. } => assert_eq!(op, BinaryOp::Mul), + _ => panic!("expected inner Mul"), + } + } + _ => panic!("expected BinaryOp, got: {expr:?}"), + } + } + + // ----------------------------------------------------------------------- + // Explicit axis syntax + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_explicit_axis() { + // `descendant::div` + let expr = p("descendant::div"); + match expr { + Expr::Path { ref steps } => { + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].axis, Axis::Descendant); + assert_eq!(steps[0].node_test, NodeTest::Name("div".to_string())); + } + _ => panic!("expected Path, got: {expr:?}"), + } + } + + // ----------------------------------------------------------------------- + // Error cases + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_empty_expression_error() { + assert_parse_error(""); + } + + #[test] + fn test_parse_unexpected_token_error() { + assert_parse_error(")"); + } + + #[test] + fn test_parse_unclosed_paren_error() { + assert_parse_error("(1 + 2"); + } + + #[test] + fn test_parse_unclosed_bracket_error() { + assert_parse_error("child[1"); + } + + // ----------------------------------------------------------------------- + // Additional coverage + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_unary_negation() { + // `-5` + let expr = p("-5"); + match expr { + Expr::UnaryNeg(inner) => match *inner { + Expr::Number(n) => assert!((n - 5.0).abs() < f64::EPSILON), + _ => panic!("expected Number inside UnaryNeg"), + }, + _ => panic!("expected UnaryNeg, got: {expr:?}"), + } + } + + #[test] + fn test_parse_double_negation() { + // `--5` + let expr = p("--5"); + match expr { + Expr::UnaryNeg(inner) => match *inner { + Expr::UnaryNeg(_) => {} // correct: double negation + _ => panic!("expected nested UnaryNeg"), + }, + _ => panic!("expected UnaryNeg, got: {expr:?}"), + } + } + + #[test] + fn test_parse_string_literal() { + let expr = p("'hello'"); + match expr { + Expr::String(s) => assert_eq!(s, "hello"), + _ => panic!("expected String, got: {expr:?}"), + } + } + + #[test] + fn test_parse_number_literal() { + let expr = p("42.5"); + match expr { + Expr::Number(n) => assert!((n - 42.5).abs() < f64::EPSILON), + _ => panic!("expected Number, got: {expr:?}"), + } + } + + #[test] + fn test_parse_comment_node_test() { + let expr = p("comment()"); + match expr { + Expr::Path { ref steps } => { + assert_eq!(steps[0].node_test, NodeTest::Comment); + } + _ => panic!("expected Path, got: {expr:?}"), + } + } + + #[test] + fn test_parse_node_node_test() { + let expr = p("node()"); + match expr { + Expr::Path { ref steps } => { + assert_eq!(steps[0].node_test, NodeTest::Node); + } + _ => panic!("expected Path, got: {expr:?}"), + } + } + + #[test] + fn test_parse_or_precedence_over_and() { + // `a and b or c and d` should be `(a and b) or (c and d)` + let expr = p("a and b or c and d"); + match expr { + Expr::BinaryOp { op, left, right } => { + assert_eq!(op, BinaryOp::Or); + match *left { + Expr::BinaryOp { op, .. } => assert_eq!(op, BinaryOp::And), + _ => panic!("expected left And"), + } + match *right { + Expr::BinaryOp { op, .. } => assert_eq!(op, BinaryOp::And), + _ => panic!("expected right And"), + } + } + _ => panic!("expected BinaryOp Or, got: {expr:?}"), + } + } + + // ----------------------------------------------------------------------- + // Filter-path expressions (issue #20) + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_filter_path_continuation() { + // `(//a)[1]/@href`: a filter expression followed by a relative + // location path must parse to FilterPath, not to a predicate. + let (inner, steps) = match p("(//a)[1]/@href") { + Expr::FilterPath { expr, steps } => (expr, steps), + other => panic!("expected FilterPath, got: {other:?}"), + }; + match *inner { + Expr::Filter { ref predicates, .. } => assert_eq!(predicates.len(), 1), + ref other => panic!("expected inner Filter, got: {other:?}"), + } + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].axis, Axis::Attribute); + assert_eq!(steps[0].node_test, NodeTest::Name("href".to_string())); + } + + #[test] + fn test_parse_filter_path_vs_predicate_distinct() { + // `(//a)/b` (path continuation) and `(//a)[b]` (predicate) must + // produce different ASTs — they mean different things. + let path = p("(//a)/b"); + assert!( + matches!(path, Expr::FilterPath { .. }), + "expected FilterPath, got: {path:?}" + ); + let filter = p("(//a)[b]"); + assert!( + matches!(filter, Expr::Filter { .. }), + "expected Filter, got: {filter:?}" + ); + } +} diff --git a/browser/vendor/xmloxide/src/xpath/regex.rs b/browser/vendor/xmloxide/src/xpath/regex.rs new file mode 100644 index 000000000..2b5057236 --- /dev/null +++ b/browser/vendor/xmloxide/src/xpath/regex.rs @@ -0,0 +1,893 @@ +//! Simple regular expression engine for `XPath` `matches()` function. +//! +//! Implements a subset of XSD/XPath regular expressions sufficient for +//! common Schematron validation patterns. This avoids depending on the +//! `regex` crate per the project's dependencies policy. +//! +//! # Supported Syntax +//! +//! - `.` — any character (except newline by default) +//! - `\d`, `\D` — digit / non-digit +//! - `\s`, `\S` — whitespace / non-whitespace +//! - `\w`, `\W` — word character / non-word character +//! - `\n`, `\r`, `\t`, `\\`, `\.` — escape sequences +//! - `[abc]`, `[a-z]`, `[^a-z]` — character classes +//! - `*`, `+`, `?` — greedy quantifiers +//! - `{n}`, `{n,}`, `{n,m}` — counted quantifiers +//! - `|` — alternation +//! - `(` `)` — grouping +//! - `^`, `$` — anchors (only meaningful with flag-based matching) + +/// Matches `input` against `pattern` (XSD/XPath regex semantics). +/// +/// By default, the pattern is anchored to match the **entire** string +/// (`XPath` `matches()` semantics: the pattern is implicitly `^...$` +/// unless the caller opts out). The `flags` parameter supports: +/// +/// - `s` — dot matches newline +/// - `m` — `^`/`$` match line boundaries (not just string boundaries) +/// - `i` — case-insensitive matching +/// - `x` — ignore whitespace in pattern +/// +/// Returns `true` if the input matches the pattern. +pub fn xpath_matches(input: &str, pattern: &str, flags: &str) -> Result<bool, String> { + let dot_all = flags.contains('s'); + let case_insensitive = flags.contains('i'); + + let compiled = compile(pattern, case_insensitive)?; + + // XPath matches() checks if the pattern matches any substring, + // NOT the entire string (unlike XSD's pattern facet). + // Per XPath 2.0 F&O section 7.6.2: "returns true if $input matches + // the regular expression". + for start in 0..=input.len() { + if !input.is_char_boundary(start) { + continue; + } + if try_match(&compiled, input, start, dot_all, case_insensitive) { + return Ok(true); + } + } + Ok(false) +} + +/// Replaces all occurrences of `pattern` in `input` with `replacement`. +/// +/// Supports `$0` in the replacement string to refer to the whole match. +/// Per `XPath` 2.0 F&O section 7.6.3. +pub fn xpath_replace( + input: &str, + pattern: &str, + replacement: &str, + flags: &str, +) -> Result<String, String> { + let dot_all = flags.contains('s'); + let case_insensitive = flags.contains('i'); + let compiled = compile(pattern, case_insensitive)?; + + let mut result = String::new(); + let mut pos = 0; + + while pos <= input.len() { + if let Some((start, end)) = + find_first_match(&compiled, input, pos, dot_all, case_insensitive) + { + // Append text before the match + result.push_str(&input[pos..start]); + // Append replacement (with $0 expansion) + for ch in replacement.chars() { + result.push(ch); + } + // Advance past the match (avoid infinite loop on zero-length match) + pos = if end == start { end + 1 } else { end }; + } else { + // No more matches — append remainder + result.push_str(&input[pos..]); + break; + } + } + + Ok(result) +} + +/// Splits `input` on occurrences of `pattern`, returning the pieces. +/// +/// Per `XPath` 2.0 F&O section 7.6.4. +pub fn xpath_tokenize(input: &str, pattern: &str, flags: &str) -> Result<Vec<String>, String> { + let dot_all = flags.contains('s'); + let case_insensitive = flags.contains('i'); + let compiled = compile(pattern, case_insensitive)?; + + if input.is_empty() { + return Ok(vec![]); + } + + let mut tokens = Vec::new(); + let mut pos = 0; + + while pos <= input.len() { + if let Some((start, end)) = + find_first_match(&compiled, input, pos, dot_all, case_insensitive) + { + // Zero-length match at current position — skip to avoid infinite loop + if end == start { + if pos < input.len() { + // Include one char and continue + let ch = input[pos..].chars().next().unwrap_or(' '); + tokens.push(input[pos..pos + ch.len_utf8()].to_string()); + pos += ch.len_utf8(); + } else { + break; + } + continue; + } + tokens.push(input[pos..start].to_string()); + pos = end; + } else { + tokens.push(input[pos..].to_string()); + break; + } + } + + Ok(tokens) +} + +/// Finds the first match of `compiled` pattern in `input` starting at `from`. +/// Returns `Some((start, end))` byte positions, or `None`. +fn find_first_match( + compiled: &[Quantified], + input: &str, + from: usize, + dot_all: bool, + ci: bool, +) -> Option<(usize, usize)> { + for start in from..=input.len() { + if !input.is_char_boundary(start) { + continue; + } + if let Some(end) = match_seq(compiled, 0, input, start, dot_all, ci) { + return Some((start, end)); + } + } + None +} + +// --------------------------------------------------------------------------- +// Compiled regex representation +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +enum RegexNode { + /// Match a single literal character. + Literal(char), + /// Match any character (`.`). + Dot, + /// Match a character class. + CharClass { + ranges: Vec<(char, char)>, + negated: bool, + }, + /// Shorthand: `\d` + Digit, + /// Shorthand: `\D` + NonDigit, + /// Shorthand: `\s` + Whitespace, + /// Shorthand: `\S` + NonWhitespace, + /// Shorthand: `\w` + WordChar, + /// Shorthand: `\W` + NonWordChar, + /// A group of alternatives separated by `|`. + Alternation(Vec<Vec<Quantified>>), +} + +#[derive(Debug, Clone)] +struct Quantified { + node: RegexNode, + quantifier: Quantifier, +} + +#[derive(Debug, Clone)] +enum Quantifier { + /// Exactly once (no quantifier). + Once, + /// `?` — zero or one. + ZeroOrOne, + /// `*` — zero or more. + ZeroOrMore, + /// `+` — one or more. + OneOrMore, + /// `{n}` — exactly n. + Exact(usize), + /// `{n,}` — at least n. + AtLeast(usize), + /// `{n,m}` — between n and m. + Between(usize, usize), +} + +// --------------------------------------------------------------------------- +// Pattern compiler +// --------------------------------------------------------------------------- + +fn compile(pattern: &str, case_insensitive: bool) -> Result<Vec<Quantified>, String> { + let chars: Vec<char> = pattern.chars().collect(); + let (result, pos) = parse_alternation(&chars, 0, case_insensitive)?; + if pos != chars.len() { + return Err(format!("unexpected character at position {pos}")); + } + Ok(result) +} + +fn parse_alternation( + chars: &[char], + start: usize, + ci: bool, +) -> Result<(Vec<Quantified>, usize), String> { + let mut alternatives: Vec<Vec<Quantified>> = Vec::new(); + let (first, mut pos) = parse_sequence(chars, start, ci)?; + alternatives.push(first); + + while pos < chars.len() && chars[pos] == '|' { + pos += 1; + let (alt, new_pos) = parse_sequence(chars, pos, ci)?; + alternatives.push(alt); + pos = new_pos; + } + + if alternatives.len() == 1 { + Ok((alternatives.into_iter().next().unwrap_or_default(), pos)) + } else { + Ok(( + vec![Quantified { + node: RegexNode::Alternation(alternatives), + quantifier: Quantifier::Once, + }], + pos, + )) + } +} + +fn parse_sequence( + chars: &[char], + mut pos: usize, + ci: bool, +) -> Result<(Vec<Quantified>, usize), String> { + let mut items = Vec::new(); + while pos < chars.len() { + match chars[pos] { + '|' | ')' => break, + '(' => { + pos += 1; + let (group, new_pos) = parse_alternation(chars, pos, ci)?; + if new_pos >= chars.len() || chars[new_pos] != ')' { + return Err("unmatched '('".to_string()); + } + pos = new_pos + 1; + // The group is a single alternation node, apply quantifier to it + let q = parse_quantifier(chars, &mut pos); + if group.len() == 1 { + items.push(Quantified { + node: group + .into_iter() + .next() + .unwrap_or(Quantified { + node: RegexNode::Literal('\0'), + quantifier: Quantifier::Once, + }) + .node, + quantifier: q, + }); + } else { + // Multi-element group → wrap in alternation with single branch + items.push(Quantified { + node: RegexNode::Alternation(vec![group]), + quantifier: q, + }); + } + } + '\\' => { + let node = parse_escape(chars, &mut pos)?; + let q = parse_quantifier(chars, &mut pos); + items.push(Quantified { + node, + quantifier: q, + }); + } + '[' => { + let node = parse_char_class(chars, &mut pos, ci)?; + let q = parse_quantifier(chars, &mut pos); + items.push(Quantified { + node, + quantifier: q, + }); + } + '.' => { + pos += 1; + let q = parse_quantifier(chars, &mut pos); + items.push(Quantified { + node: RegexNode::Dot, + quantifier: q, + }); + } + '^' | '$' => { + // Anchors — skip them (we try all start positions anyway) + pos += 1; + } + ch => { + pos += 1; + let q = parse_quantifier(chars, &mut pos); + let lit = if ci { ch.to_ascii_lowercase() } else { ch }; + items.push(Quantified { + node: RegexNode::Literal(lit), + quantifier: q, + }); + } + } + } + Ok((items, pos)) +} + +fn parse_escape(chars: &[char], pos: &mut usize) -> Result<RegexNode, String> { + *pos += 1; // skip '\' + if *pos >= chars.len() { + return Err("trailing backslash".to_string()); + } + let ch = chars[*pos]; + *pos += 1; + Ok(match ch { + 'd' => RegexNode::Digit, + 'D' => RegexNode::NonDigit, + 's' => RegexNode::Whitespace, + 'S' => RegexNode::NonWhitespace, + 'w' => RegexNode::WordChar, + 'W' => RegexNode::NonWordChar, + 'n' => RegexNode::Literal('\n'), + 'r' => RegexNode::Literal('\r'), + 't' => RegexNode::Literal('\t'), + _ => RegexNode::Literal(ch), // \., \\, \[, etc. + }) +} + +fn parse_char_class(chars: &[char], pos: &mut usize, ci: bool) -> Result<RegexNode, String> { + *pos += 1; // skip '[' + let negated = *pos < chars.len() && chars[*pos] == '^'; + if negated { + *pos += 1; + } + + let mut ranges = Vec::new(); + while *pos < chars.len() && chars[*pos] != ']' { + let start_ch = if chars[*pos] == '\\' { + *pos += 1; + if *pos >= chars.len() { + return Err("trailing backslash in character class".to_string()); + } + let esc = chars[*pos]; + *pos += 1; + match esc { + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + 'd' | 'D' | 's' | 'S' | 'w' | 'W' => { + // Shorthand in class — expand + let shorthand_ranges = shorthand_to_ranges(esc); + ranges.extend(shorthand_ranges); + continue; + } + _ => esc, + } + } else { + let ch = chars[*pos]; + *pos += 1; + ch + }; + + if *pos + 1 < chars.len() && chars[*pos] == '-' && chars[*pos + 1] != ']' { + *pos += 1; // skip '-' + let end_ch = if chars[*pos] == '\\' { + *pos += 1; + if *pos >= chars.len() { + return Err("trailing backslash in character class".to_string()); + } + let esc = chars[*pos]; + *pos += 1; + match esc { + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + _ => esc, + } + } else { + let ch = chars[*pos]; + *pos += 1; + ch + }; + let (lo, hi) = if ci { + (start_ch.to_ascii_lowercase(), end_ch.to_ascii_lowercase()) + } else { + (start_ch, end_ch) + }; + ranges.push((lo, hi)); + } else { + let lit = if ci { + start_ch.to_ascii_lowercase() + } else { + start_ch + }; + ranges.push((lit, lit)); + } + } + if *pos < chars.len() { + *pos += 1; // skip ']' + } else { + return Err("unmatched '['".to_string()); + } + Ok(RegexNode::CharClass { ranges, negated }) +} + +fn shorthand_to_ranges(ch: char) -> Vec<(char, char)> { + match ch { + 'd' => vec![('0', '9')], + 'D' => vec![('\0', '/'), (':', char::MAX)], + 's' => vec![(' ', ' '), ('\t', '\t'), ('\n', '\n'), ('\r', '\r')], + 'S' => vec![('!', char::MAX)], // simplified + 'w' => vec![('a', 'z'), ('A', 'Z'), ('0', '9'), ('_', '_')], + 'W' => vec![ + ('\0', '/'), + (':', '@'), + ('[', '^'), + ('`', '`'), + ('{', char::MAX), + ], + _ => vec![], + } +} + +fn parse_quantifier(chars: &[char], pos: &mut usize) -> Quantifier { + if *pos >= chars.len() { + return Quantifier::Once; + } + match chars[*pos] { + '?' => { + *pos += 1; + Quantifier::ZeroOrOne + } + '*' => { + *pos += 1; + Quantifier::ZeroOrMore + } + '+' => { + *pos += 1; + Quantifier::OneOrMore + } + '{' => { + let start = *pos; + *pos += 1; + if let Some(q) = parse_counted_quantifier(chars, pos) { + q + } else { + *pos = start; // revert + Quantifier::Once + } + } + _ => Quantifier::Once, + } +} + +fn parse_counted_quantifier(chars: &[char], pos: &mut usize) -> Option<Quantifier> { + let n = parse_uint(chars, pos)?; + if *pos >= chars.len() { + return None; + } + if chars[*pos] == '}' { + *pos += 1; + return Some(Quantifier::Exact(n)); + } + if chars[*pos] != ',' { + return None; + } + *pos += 1; // skip ',' + if *pos >= chars.len() { + return None; + } + if chars[*pos] == '}' { + *pos += 1; + return Some(Quantifier::AtLeast(n)); + } + let m = parse_uint(chars, pos)?; + if *pos < chars.len() && chars[*pos] == '}' { + *pos += 1; + Some(Quantifier::Between(n, m)) + } else { + None + } +} + +fn parse_uint(chars: &[char], pos: &mut usize) -> Option<usize> { + let start = *pos; + while *pos < chars.len() && chars[*pos].is_ascii_digit() { + *pos += 1; + } + if *pos == start { + return None; + } + chars[start..*pos].iter().collect::<String>().parse().ok() +} + +// --------------------------------------------------------------------------- +// Matching engine (backtracking) +// --------------------------------------------------------------------------- + +fn try_match(pattern: &[Quantified], input: &str, start: usize, dot_all: bool, ci: bool) -> bool { + match_seq(pattern, 0, input, start, dot_all, ci).is_some() +} + +/// Tries to match `pattern[pat_idx..]` against `input[input_pos..]`. +/// Returns the end position in input if successful. +fn match_seq( + pattern: &[Quantified], + pat_idx: usize, + input: &str, + input_pos: usize, + dot_all: bool, + ci: bool, +) -> Option<usize> { + if pat_idx >= pattern.len() { + return Some(input_pos); + } + + let item = &pattern[pat_idx]; + match &item.quantifier { + Quantifier::Once => { + let end = match_node_once(&item.node, input, input_pos, dot_all, ci)?; + match_seq(pattern, pat_idx + 1, input, end, dot_all, ci) + } + Quantifier::ZeroOrOne => { + // Try matching once first (greedy) + if let Some(end) = match_node_once(&item.node, input, input_pos, dot_all, ci) { + if let Some(result) = match_seq(pattern, pat_idx + 1, input, end, dot_all, ci) { + return Some(result); + } + } + // Try matching zero times + match_seq(pattern, pat_idx + 1, input, input_pos, dot_all, ci) + } + Quantifier::ZeroOrMore => match_greedy( + &item.node, + pattern, + pat_idx, + input, + input_pos, + 0, + usize::MAX, + dot_all, + ci, + ), + Quantifier::OneOrMore => match_greedy( + &item.node, + pattern, + pat_idx, + input, + input_pos, + 1, + usize::MAX, + dot_all, + ci, + ), + Quantifier::Exact(n) => match_greedy( + &item.node, pattern, pat_idx, input, input_pos, *n, *n, dot_all, ci, + ), + Quantifier::AtLeast(n) => match_greedy( + &item.node, + pattern, + pat_idx, + input, + input_pos, + *n, + usize::MAX, + dot_all, + ci, + ), + Quantifier::Between(n, m) => match_greedy( + &item.node, pattern, pat_idx, input, input_pos, *n, *m, dot_all, ci, + ), + } +} + +/// Greedy matching: consume as many as possible (up to max), then backtrack. +#[allow(clippy::too_many_arguments)] +fn match_greedy( + node: &RegexNode, + pattern: &[Quantified], + pat_idx: usize, + input: &str, + start: usize, + min: usize, + max: usize, + dot_all: bool, + ci: bool, +) -> Option<usize> { + // Collect all possible match positions + let mut positions = vec![start]; + let mut pos = start; + let mut count = 0; + while count < max { + if let Some(end) = match_node_once(node, input, pos, dot_all, ci) { + positions.push(end); + pos = end; + count += 1; + } else { + break; + } + } + + // Try from most matches (greedy) down to min + for i in (min..=positions.len().saturating_sub(1)).rev() { + if let Some(result) = match_seq(pattern, pat_idx + 1, input, positions[i], dot_all, ci) { + return Some(result); + } + } + None +} + +/// Tries to match a single node at the given position. +/// Returns the position after the match, or None. +fn match_node_once( + node: &RegexNode, + input: &str, + pos: usize, + dot_all: bool, + ci: bool, +) -> Option<usize> { + match node { + RegexNode::Literal(expected) => { + let ch = get_char(input, pos)?; + let matched = if ci { + ch.eq_ignore_ascii_case(expected) + } else { + ch == *expected + }; + matched.then(|| pos + ch.len_utf8()) + } + RegexNode::Dot => { + let ch = get_char(input, pos)?; + (ch != '\n' || dot_all).then(|| pos + ch.len_utf8()) + } + RegexNode::CharClass { ranges, negated } => { + let ch = get_char(input, pos)?; + let test_ch = if ci { ch.to_ascii_lowercase() } else { ch }; + let in_class = ranges + .iter() + .any(|&(lo, hi)| test_ch >= lo && test_ch <= hi); + (in_class ^ negated).then(|| pos + ch.len_utf8()) + } + RegexNode::Digit => { + let ch = get_char(input, pos)?; + ch.is_ascii_digit().then(|| pos + ch.len_utf8()) + } + RegexNode::NonDigit => { + let ch = get_char(input, pos)?; + (!ch.is_ascii_digit()).then(|| pos + ch.len_utf8()) + } + RegexNode::Whitespace => { + let ch = get_char(input, pos)?; + ch.is_ascii_whitespace().then(|| pos + ch.len_utf8()) + } + RegexNode::NonWhitespace => { + let ch = get_char(input, pos)?; + (!ch.is_ascii_whitespace()).then(|| pos + ch.len_utf8()) + } + RegexNode::WordChar => { + let ch = get_char(input, pos)?; + (ch.is_ascii_alphanumeric() || ch == '_').then(|| pos + ch.len_utf8()) + } + RegexNode::NonWordChar => { + let ch = get_char(input, pos)?; + (!ch.is_ascii_alphanumeric() && ch != '_').then(|| pos + ch.len_utf8()) + } + RegexNode::Alternation(alternatives) => { + for alt in alternatives { + if let Some(end) = match_seq(alt, 0, input, pos, dot_all, ci) { + return Some(end); + } + } + None + } + } +} + +fn get_char(input: &str, pos: usize) -> Option<char> { + input[pos..].chars().next() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn matches(input: &str, pattern: &str) -> bool { + xpath_matches(input, pattern, "").unwrap() + } + + fn matches_flags(input: &str, pattern: &str, flags: &str) -> bool { + xpath_matches(input, pattern, flags).unwrap() + } + + #[test] + fn test_literal() { + assert!(matches("hello", "hello")); + assert!(matches("hello world", "hello")); + assert!(!matches("world", "hello")); + } + + #[test] + fn test_dot() { + assert!(matches("a", ".")); + assert!(matches("abc", "a.c")); + assert!(!matches("ac", "a.c")); + } + + #[test] + fn test_quantifiers() { + assert!(matches("aaa", "a+")); + assert!(matches("", "a*")); + assert!(matches("a", "a?")); + assert!(matches("", "a?")); + assert!(!matches("", "a+")); + } + + #[test] + fn test_char_class() { + assert!(matches("a", "[abc]")); + assert!(matches("b", "[abc]")); + assert!(!matches("d", "[abc]")); + assert!(matches("m", "[a-z]")); + assert!(!matches("M", "[a-z]")); + } + + #[test] + fn test_negated_class() { + assert!(!matches("a", "[^abc]")); + assert!(matches("d", "[^abc]")); + } + + #[test] + fn test_shorthand() { + assert!(matches("5", "\\d")); + assert!(!matches("a", "\\d")); + assert!(matches("a", "\\D")); + assert!(matches(" ", "\\s")); + assert!(matches("a", "\\w")); + } + + #[test] + fn test_counted_quantifier() { + assert!(matches("aa", "a{2}")); + assert!(!matches("a", "a{2}")); + assert!(matches("aaa", "a{2,}")); + assert!(matches("aa", "a{2,4}")); + assert!(matches("aaaa", "a{2,4}")); + assert!(!matches("a", "a{2,4}")); + } + + #[test] + fn test_alternation() { + assert!(matches("cat", "cat|dog")); + assert!(matches("dog", "cat|dog")); + assert!(!matches("bird", "cat|dog")); + } + + #[test] + fn test_grouping() { + assert!(matches("abab", "(ab)+")); + assert!(matches("abc", "(ab)+")); // substring match — "ab" prefix matches + assert!(!matches("cd", "(ab)+")); + } + + #[test] + fn test_country_code_pattern() { + // Common Schematron pattern for ISO country codes + assert!(matches("US", "[A-Z]{2}")); + assert!(matches("GB", "[A-Z]{2}")); + assert!(!matches("us", "[A-Z]{2}")); + assert!(!matches("U", "[A-Z]{2}")); + } + + #[test] + fn test_invoice_id_pattern() { + assert!(matches("INV-2026-001", "[A-Z]+-\\d+-\\d+")); + assert!(!matches("inv-2026-001", "[A-Z]+-\\d+-\\d+")); + } + + #[test] + fn test_email_like_pattern() { + assert!(matches("user@example.com", ".+@.+\\..+")); + assert!(!matches("noatsign", ".+@.+\\..+")); + } + + #[test] + fn test_case_insensitive() { + assert!(matches_flags("Hello", "hello", "i")); + assert!(matches_flags("HELLO", "hello", "i")); + assert!(matches_flags("us", "[A-Z]{2}", "i")); + } + + #[test] + fn test_substring_match() { + // XPath matches() checks for substring match, not full string match + assert!(matches("hello world", "world")); + assert!(matches("abc123def", "\\d+")); + } + + #[test] + fn test_escaped_special() { + assert!(matches("a.b", "a\\.b")); + assert!(!matches("axb", "a\\.b")); + assert!(matches("a\\b", "a\\\\b")); + } + + #[test] + fn test_empty_pattern() { + assert!(matches("anything", "")); + assert!(matches("", "")); + } + + // -- replace tests -- + + fn replace(input: &str, pattern: &str, replacement: &str) -> String { + xpath_replace(input, pattern, replacement, "").unwrap() + } + + #[test] + fn test_replace_literal() { + assert_eq!(replace("hello world", "world", "rust"), "hello rust"); + } + + #[test] + fn test_replace_pattern() { + assert_eq!(replace("abc123def", "\\d+", "NUM"), "abcNUMdef"); + } + + #[test] + fn test_replace_multiple() { + assert_eq!(replace("a-b-c", "-", "_"), "a_b_c"); + } + + #[test] + fn test_replace_no_match() { + assert_eq!(replace("hello", "xyz", "!"), "hello"); + } + + // -- tokenize tests -- + + fn tokenize(input: &str, pattern: &str) -> Vec<String> { + xpath_tokenize(input, pattern, "").unwrap() + } + + #[test] + fn test_tokenize_whitespace() { + assert_eq!(tokenize("a b c", "\\s+"), vec!["a", "b", "c"]); + } + + #[test] + fn test_tokenize_comma() { + assert_eq!(tokenize("one,two,three", ","), vec!["one", "two", "three"]); + } + + #[test] + fn test_tokenize_empty() { + let result: Vec<String> = tokenize("", ","); + assert!(result.is_empty()); + } + + #[test] + fn test_tokenize_no_match() { + assert_eq!(tokenize("hello", ","), vec!["hello"]); + } +} diff --git a/browser/vendor/xmloxide/src/xpath/types.rs b/browser/vendor/xmloxide/src/xpath/types.rs new file mode 100644 index 000000000..fa7c27b16 --- /dev/null +++ b/browser/vendor/xmloxide/src/xpath/types.rs @@ -0,0 +1,1007 @@ +//! `XPath` 1.0 value type system. +//! +//! This module implements the four core data types defined in the `XPath` 1.0 +//! specification (<https://www.w3.org/TR/xpath-10/#section-Data-Model>): +//! boolean, number, string, and node-set. +//! +//! It also provides type conversion methods per `XPath` 1.0 sections 4.1 +//! through 4.4, comparison helpers per section 3.4, and number formatting +//! rules per the spec's string conversion requirements. + +use crate::tree::NodeId; +use std::fmt; + +// --------------------------------------------------------------------------- +// XPathNode +// --------------------------------------------------------------------------- + +/// A node in an `XPath` node-set. +/// +/// The `XPath` 1.0 data model (section 5) includes attribute nodes, but the +/// tree arena stores attributes inline on their owner element rather than as +/// tree nodes. This enum lets node-sets carry both kinds: ordinary tree +/// nodes by [`NodeId`], and attributes by owner element plus position in the +/// element's attribute list. +/// +/// Ordering follows document order: an attribute sorts immediately after its +/// owner element (and before the element's children — sufficient for the +/// stable, consistent ordering `XPath` 1.0 section 5.3 requires), and +/// attributes of the same element sort by attribute index. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum XPathNode { + /// A tree node (element, text, comment, PI, or the document root). + Node(NodeId), + /// An attribute node, identified by its owner element and its index in + /// the element's attribute list. + Attribute { + /// The element that carries the attribute. + owner: NodeId, + /// The index of the attribute in the owner's attribute list. + index: u32, + }, +} + +impl XPathNode { + /// Returns the tree node this entry anchors to: the node itself, or the + /// owner element for an attribute. + #[must_use] + pub fn anchor(self) -> NodeId { + match self { + Self::Node(id) => id, + Self::Attribute { owner, .. } => owner, + } + } + + /// Returns the inner [`NodeId`] if this is a tree node, or `None` for + /// an attribute. + #[must_use] + pub fn as_tree_node(self) -> Option<NodeId> { + match self { + Self::Node(id) => Some(id), + Self::Attribute { .. } => None, + } + } + + /// Returns `true` if this entry is an attribute node. + #[must_use] + pub fn is_attribute(self) -> bool { + matches!(self, Self::Attribute { .. }) + } + + /// Document-order sort key: `(anchor, is-attribute, attribute-index)`. + /// + /// Relies on the arena property that `NodeId`s of a parsed document are + /// allocated in document order. + fn sort_key(self) -> (NodeId, u8, u32) { + match self { + Self::Node(id) => (id, 0, 0), + Self::Attribute { owner, index } => (owner, 1, index), + } + } +} + +impl Ord for XPathNode { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.sort_key().cmp(&other.sort_key()) + } +} + +impl PartialOrd for XPathNode { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + Some(self.cmp(other)) + } +} + +impl From<NodeId> for XPathNode { + fn from(id: NodeId) -> Self { + Self::Node(id) + } +} + +// --------------------------------------------------------------------------- +// XPathValue +// --------------------------------------------------------------------------- + +/// An `XPath` 1.0 value. +/// +/// The `XPath` specification defines exactly four data types. Every expression +/// evaluates to one of these variants. +/// See <https://www.w3.org/TR/xpath-10/#section-Data-Model>. +#[derive(Debug, Clone)] +pub enum XPathValue { + /// A boolean value (`true` or `false`). + /// + /// See `XPath` 1.0 section 4.3. + Boolean(bool), + + /// A number (IEEE 754 double-precision floating-point). + /// + /// Numbers follow IEEE 754 semantics including NaN, positive and + /// negative infinity, and negative zero. See `XPath` 1.0 section 4.4. + Number(f64), + + /// A string (a sequence of UCS characters). + /// + /// See `XPath` 1.0 section 4.2. + String(String), + + /// An ordered set of nodes. + /// + /// Node-sets are ordered in document order and may contain both tree + /// nodes and attribute nodes (see [`XPathNode`]). Duplicates should not + /// appear (the caller is responsible for deduplication). See `XPath` 1.0 + /// section 3.3. + NodeSet(Vec<XPathNode>), +} + +impl XPathValue { + // -- Type conversion methods (`XPath` 1.0 sections 4.1-4.4) ------------- + + /// Converts this value to a boolean. + /// + /// Conversion rules per `XPath` 1.0 section 4.3: + /// - **boolean**: identity + /// - **number**: `false` if the number is zero or NaN, `true` otherwise + /// - **string**: `false` if the string is empty, `true` otherwise + /// - **node-set**: `false` if the node-set is empty, `true` otherwise + #[must_use] + pub fn to_boolean(&self) -> bool { + match self { + Self::Boolean(b) => *b, + Self::Number(n) => *n != 0.0 && !n.is_nan(), + Self::String(s) => !s.is_empty(), + Self::NodeSet(nodes) => !nodes.is_empty(), + } + } + + /// Converts this value to a number. + /// + /// Conversion rules per `XPath` 1.0 section 4.4: + /// - **number**: identity + /// - **boolean**: `true` becomes `1.0`, `false` becomes `0.0` + /// - **string**: parsed as an IEEE 754 number; unparseable strings become NaN + /// - **node-set**: the string-value of the first node in document order is + /// converted as a string; an empty node-set converts to NaN + /// + /// Note: the node-set conversion requires a `Document` to compute + /// string-values. Without one, we return NaN as a fallback. For full + /// node-set conversion, use + /// [`to_number_with_string_value`](Self::to_number_with_string_value). + #[must_use] + pub fn to_number(&self) -> f64 { + match self { + Self::Number(n) => *n, + Self::Boolean(b) => { + if *b { + 1.0 + } else { + 0.0 + } + } + Self::String(s) => parse_xpath_number(s), + Self::NodeSet(_) => { + // Without a Document reference we cannot compute the + // string-value of the first node. Return NaN as a safe + // fallback; callers that hold a Document should use + // `to_number_with_string_value` instead. + f64::NAN + } + } + } + + /// Converts this value to a number, using a pre-computed string-value + /// for the first node in a node-set. + /// + /// This is the correct conversion for node-sets when the caller has access + /// to the `Document` and can supply the string-value of the first node. + /// For non-node-set values, the `first_node_string_value` parameter is + /// ignored. + #[must_use] + pub fn to_number_with_string_value(&self, first_node_string_value: Option<&str>) -> f64 { + match self { + Self::NodeSet(_) => first_node_string_value.map_or(f64::NAN, parse_xpath_number), + _ => self.to_number(), + } + } + + /// Converts this value to a string per the `XPath` `string()` function. + /// + /// Conversion rules per `XPath` 1.0 section 4.2: + /// - **string**: identity + /// - **boolean**: `"true"` or `"false"` + /// - **number**: formatted per `XPath` number-to-string rules (see + /// [`format_xpath_number`]) + /// - **node-set**: the string-value of the first node in document order; + /// an empty node-set yields the empty string + /// + /// Note: the node-set conversion requires a `Document` to compute + /// string-values. Without one, we return the empty string for node-sets. + /// For full conversion, use + /// [`to_string_with_string_value`](Self::to_string_with_string_value). + #[must_use] + pub fn to_xpath_string(&self) -> String { + match self { + Self::String(s) => s.clone(), + Self::Boolean(b) => { + if *b { + "true".to_owned() + } else { + "false".to_owned() + } + } + Self::Number(n) => format_xpath_number(*n), + Self::NodeSet(_) => { + // Without a Document we cannot compute string-values. + String::new() + } + } + } + + /// Converts this value to a string, using a pre-computed string-value + /// for the first node in a node-set. + /// + /// For non-node-set values the `first_node_string_value` parameter is + /// ignored. + #[must_use] + pub fn to_string_with_string_value(&self, first_node_string_value: Option<&str>) -> String { + match self { + Self::NodeSet(_) => first_node_string_value.map_or_else(String::new, str::to_owned), + _ => self.to_xpath_string(), + } + } + + /// Returns a reference to the inner node-set if this value is a + /// `NodeSet`, or `None` otherwise. + #[must_use] + pub fn as_node_set(&self) -> Option<&Vec<XPathNode>> { + match self { + Self::NodeSet(nodes) => Some(nodes), + _ => None, + } + } + + /// Returns a human-readable name for the type of this value. + /// + /// Useful for error messages. + #[must_use] + pub fn type_name(&self) -> &'static str { + match self { + Self::Boolean(_) => "boolean", + Self::Number(_) => "number", + Self::String(_) => "string", + Self::NodeSet(_) => "node-set", + } + } +} + +impl fmt::Display for XPathValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Boolean(b) => { + if *b { + write!(f, "true") + } else { + write!(f, "false") + } + } + Self::Number(n) => write!(f, "{}", format_xpath_number(*n)), + Self::String(s) => write!(f, "{s}"), + Self::NodeSet(nodes) => write!(f, "<node-set of {} nodes>", nodes.len()), + } + } +} + +impl PartialEq for XPathValue { + #[allow(clippy::float_cmp)] + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Boolean(a), Self::Boolean(b)) => a == b, + (Self::Number(a), Self::Number(b)) => { + // NaN != NaN per IEEE 754 and XPath spec + a == b + } + (Self::String(a), Self::String(b)) => a == b, + (Self::NodeSet(a), Self::NodeSet(b)) => a == b, + _ => false, + } + } +} + +// --------------------------------------------------------------------------- +// Number formatting (XPath 1.0 section 4.2, number-to-string conversion) +// --------------------------------------------------------------------------- + +/// Formats an `f64` as a string per the `XPath` number-to-string rules. +/// +/// Formatting rules per `XPath` 1.0 section 4.2 (the `string()` function +/// applied to numbers): +/// - NaN produces `"NaN"` +/// - Positive infinity produces `"Infinity"` +/// - Negative infinity produces `"-Infinity"` +/// - Negative zero produces `"0"` (not `"-0"`) +/// - If the number is an integer (no fractional part), it is formatted +/// without a decimal point (e.g., `1.0` becomes `"1"`) +/// - Otherwise, standard decimal notation is used with no trailing zeros +#[must_use] +pub fn format_xpath_number(n: f64) -> String { + if n.is_nan() { + return "NaN".to_owned(); + } + if n.is_infinite() { + return if n.is_sign_positive() { + "Infinity".to_owned() + } else { + "-Infinity".to_owned() + }; + } + // Negative zero: XPath requires "0", not "-0". + if n == 0.0 { + return "0".to_owned(); + } + // If the number is a mathematical integer, format without decimal point. + // We check with fract() == 0.0 AND that the number is within the safe + // integer range for f64 so that very large floats don't produce overly + // long strings. + #[allow(clippy::float_cmp, clippy::cast_possible_truncation)] + if n.fract() == 0.0 && n.abs() < 1e18 { + // Format as integer (no decimal point). + return format!("{}", n as i64); + } + // General decimal formatting. Rust's default f64 Display uses enough + // digits to round-trip, which matches XPath's requirement of producing + // a string that converts back to the same number. + format!("{n}") +} + +// --------------------------------------------------------------------------- +// Number parsing (for string-to-number conversion) +// --------------------------------------------------------------------------- + +/// Parses a string into an `XPath` number. +/// +/// `XPath` 1.0 section 4.4 defines the `number()` function for strings: +/// the string is trimmed of leading/trailing whitespace and parsed as +/// an optional sign, digits, optional decimal point, and optional +/// fractional digits. Anything that does not match produces NaN. +/// +/// Note: the `XPath` string-to-number conversion is stricter than Rust's +/// `f64::parse` in some respects (no hex, no exponent notation), but +/// more lenient in others (leading/trailing whitespace is allowed). We use +/// `f64::parse` on the trimmed string as a reasonable approximation. +fn parse_xpath_number(s: &str) -> f64 { + let trimmed = s.trim(); + if trimmed.is_empty() { + return f64::NAN; + } + trimmed.parse::<f64>().unwrap_or(f64::NAN) +} + +// --------------------------------------------------------------------------- +// XPathError +// --------------------------------------------------------------------------- + +/// An error that can occur during `XPath` expression parsing or evaluation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum XPathError { + /// A type mismatch occurred (e.g., expected a node-set but got a string). + TypeError { + /// The type that was expected. + expected: String, + /// The type that was actually found. + found: String, + }, + + /// A variable reference used a name that has no binding in the current + /// context. + UndefinedVariable { + /// The name of the undefined variable (without the `$` prefix). + name: String, + }, + + /// A function call used a name that is not a core `XPath` function and has + /// no extension binding. + UndefinedFunction { + /// The name of the undefined function. + name: String, + }, + + /// A function was called with the wrong number of arguments. + InvalidArgCount { + /// The name of the function. + function: String, + /// The number of arguments expected. + expected: usize, + /// The number of arguments that were actually provided. + found: usize, + }, + + /// The `XPath` expression could not be parsed. + InvalidExpression { + /// A description of the parse error. + message: String, + }, + + /// An unexpected internal error occurred during evaluation. + InternalError { + /// A description of the internal error. + message: String, + }, +} + +impl fmt::Display for XPathError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::TypeError { expected, found } => { + write!(f, "type error: expected {expected}, found {found}") + } + Self::UndefinedVariable { name } => { + write!(f, "undefined variable: ${name}") + } + Self::UndefinedFunction { name } => { + write!(f, "undefined function: {name}()") + } + Self::InvalidArgCount { + function, + expected, + found, + } => { + write!( + f, + "invalid argument count for {function}(): \ + expected {expected}, found {found}" + ) + } + Self::InvalidExpression { message } => { + write!(f, "invalid XPath expression: {message}") + } + Self::InternalError { message } => { + write!(f, "internal XPath error: {message}") + } + } + } +} + +impl std::error::Error for XPathError {} + +impl From<super::lexer::XPathError> for XPathError { + fn from(err: super::lexer::XPathError) -> Self { + Self::InvalidExpression { + message: err.to_string(), + } + } +} + +// --------------------------------------------------------------------------- +// Comparison helpers (XPath 1.0 section 3.4) +// --------------------------------------------------------------------------- + +/// Compares two values for equality using `XPath` 1.0 section 3.4 rules. +/// +/// The comparison semantics depend on the types of the operands: +/// - If either operand is a boolean, the other is converted to boolean. +/// - If either operand is a number, the other is converted to a number. +/// - Otherwise, both are converted to strings. +/// +/// Node-set comparisons are handled specially: when comparing a node-set to +/// a non-node-set value, the comparison is true if any node's string-value +/// satisfies the comparison against the other value. +/// +/// This function does not handle node-set comparisons (which require access +/// to the document for string-value computation). For those, use +/// [`compare_values_with_string_values`]. +/// +/// Returns `None` if the comparison involves a node-set (since we cannot +/// compute string-values without a `Document`). +#[must_use] +pub fn compare_values_eq(lhs: &XPathValue, rhs: &XPathValue) -> Option<bool> { + // Node-set comparisons require document access for string-values + if matches!(lhs, XPathValue::NodeSet(_)) || matches!(rhs, XPathValue::NodeSet(_)) { + return None; + } + Some(compare_non_nodeset_eq(lhs, rhs)) +} + +/// Compares two non-node-set values for equality. +/// +/// Per `XPath` 1.0 section 3.4: +/// - If either is a boolean, compare as booleans. +/// - Else if either is a number, compare as numbers. +/// - Else compare as strings. +#[allow(clippy::float_cmp)] +fn compare_non_nodeset_eq(lhs: &XPathValue, rhs: &XPathValue) -> bool { + // If either is boolean, compare as booleans. + if matches!(lhs, XPathValue::Boolean(_)) || matches!(rhs, XPathValue::Boolean(_)) { + return lhs.to_boolean() == rhs.to_boolean(); + } + // If either is a number, compare as numbers. + if matches!(lhs, XPathValue::Number(_)) || matches!(rhs, XPathValue::Number(_)) { + let ln = lhs.to_number(); + let rn = rhs.to_number(); + return ln == rn; + } + // Otherwise compare as strings. + lhs.to_xpath_string() == rhs.to_xpath_string() +} + +/// Compares two values for equality, using pre-computed string-values +/// for nodes in node-sets. +/// +/// `lhs_strings` and `rhs_strings` supply the string-values for each node +/// in the respective node-set (in the same order as the nodes appear in the +/// `NodeSet` vector). For non-node-set operands, the corresponding strings +/// parameter is ignored. +/// +/// This implements the full `XPath` 1.0 section 3.4 equality semantics, +/// including node-set-to-node-set and node-set-to-scalar comparisons. +#[must_use] +#[allow(clippy::float_cmp)] +pub fn compare_values_with_string_values( + lhs: &XPathValue, + rhs: &XPathValue, + lhs_strings: &[String], + rhs_strings: &[String], +) -> bool { + match (lhs, rhs) { + // node-set = node-set: true if any pair of string-values are equal + (XPathValue::NodeSet(_), XPathValue::NodeSet(_)) => { + for ls in lhs_strings { + for rs in rhs_strings { + if ls == rs { + return true; + } + } + } + false + } + + // node-set = boolean: convert node-set to boolean first + (XPathValue::NodeSet(nodes), XPathValue::Boolean(b)) + | (XPathValue::Boolean(b), XPathValue::NodeSet(nodes)) => nodes.is_empty() != *b, + + // node-set = number: convert each node's string-value to a number + (XPathValue::NodeSet(_), XPathValue::Number(n)) => { + lhs_strings.iter().any(|sv| parse_xpath_number(sv) == *n) + } + (XPathValue::Number(n), XPathValue::NodeSet(_)) => { + rhs_strings.iter().any(|sv| parse_xpath_number(sv) == *n) + } + + // node-set = string: compare each node's string-value to the string + (XPathValue::NodeSet(_), XPathValue::String(s)) => lhs_strings.iter().any(|sv| sv == s), + (XPathValue::String(s), XPathValue::NodeSet(_)) => rhs_strings.iter().any(|sv| sv == s), + + // non-node-set comparisons + _ => compare_non_nodeset_eq(lhs, rhs), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::float_cmp, clippy::unwrap_used)] +mod tests { + use super::*; + use crate::tree::{Document, NodeKind}; + + /// Helper to create a `NodeId` for testing purposes by allocating a real + /// node in a document arena. + fn make_node_id_in_doc(doc: &mut Document) -> NodeId { + doc.create_node(NodeKind::Text { + content: String::new(), + }) + } + + // -- Boolean conversion ------------------------------------------------- + + #[test] + fn test_to_boolean_from_boolean() { + assert!(XPathValue::Boolean(true).to_boolean()); + assert!(!XPathValue::Boolean(false).to_boolean()); + } + + #[test] + fn test_to_boolean_from_number() { + assert!(XPathValue::Number(1.0).to_boolean()); + assert!(XPathValue::Number(-1.0).to_boolean()); + assert!(XPathValue::Number(0.5).to_boolean()); + // Zero and NaN are false + assert!(!XPathValue::Number(0.0).to_boolean()); + assert!(!XPathValue::Number(-0.0).to_boolean()); + assert!(!XPathValue::Number(f64::NAN).to_boolean()); + } + + #[test] + fn test_to_boolean_from_string() { + assert!(XPathValue::String("hello".to_owned()).to_boolean()); + assert!(XPathValue::String(" ".to_owned()).to_boolean()); + assert!(!XPathValue::String(String::new()).to_boolean()); + } + + #[test] + fn test_to_boolean_from_nodeset() { + let mut doc = Document::new(); + let id = make_node_id_in_doc(&mut doc); + assert!(XPathValue::NodeSet(vec![id.into()]).to_boolean()); + assert!(!XPathValue::NodeSet(vec![]).to_boolean()); + } + + // -- Number conversion -------------------------------------------------- + + #[test] + fn test_to_number_from_number() { + assert_eq!(XPathValue::Number(42.0).to_number(), 42.0); + assert!(XPathValue::Number(f64::NAN).to_number().is_nan()); + } + + #[test] + fn test_to_number_from_boolean() { + assert_eq!(XPathValue::Boolean(true).to_number(), 1.0); + assert_eq!(XPathValue::Boolean(false).to_number(), 0.0); + } + + #[test] + fn test_to_number_from_string() { + assert_eq!(XPathValue::String("42".to_owned()).to_number(), 42.0); + assert_eq!(XPathValue::String(" 3.5 ".to_owned()).to_number(), 3.5); + assert_eq!(XPathValue::String("-7".to_owned()).to_number(), -7.0); + assert!(XPathValue::String("not a number".to_owned()) + .to_number() + .is_nan()); + assert!(XPathValue::String(String::new()).to_number().is_nan()); + } + + #[test] + fn test_to_number_from_nodeset_without_doc() { + let mut doc = Document::new(); + let id = make_node_id_in_doc(&mut doc); + assert!(XPathValue::NodeSet(vec![id.into()]).to_number().is_nan()); + } + + #[test] + fn test_to_number_with_string_value() { + let mut doc = Document::new(); + let id = make_node_id_in_doc(&mut doc); + let val = XPathValue::NodeSet(vec![id.into()]); + assert_eq!(val.to_number_with_string_value(Some("42")), 42.0); + assert!(val.to_number_with_string_value(Some("abc")).is_nan()); + assert!(val.to_number_with_string_value(None).is_nan()); + } + + // -- String conversion -------------------------------------------------- + + #[test] + fn test_to_xpath_string_from_string() { + assert_eq!( + XPathValue::String("hello".to_owned()).to_xpath_string(), + "hello" + ); + } + + #[test] + fn test_to_xpath_string_from_boolean() { + assert_eq!(XPathValue::Boolean(true).to_xpath_string(), "true"); + assert_eq!(XPathValue::Boolean(false).to_xpath_string(), "false"); + } + + #[test] + fn test_to_xpath_string_from_number() { + assert_eq!(XPathValue::Number(1.0).to_xpath_string(), "1"); + assert_eq!(XPathValue::Number(-1.0).to_xpath_string(), "-1"); + assert_eq!(XPathValue::Number(0.0).to_xpath_string(), "0"); + assert_eq!(XPathValue::Number(1.5).to_xpath_string(), "1.5"); + assert_eq!(XPathValue::Number(f64::NAN).to_xpath_string(), "NaN"); + assert_eq!( + XPathValue::Number(f64::INFINITY).to_xpath_string(), + "Infinity" + ); + assert_eq!( + XPathValue::Number(f64::NEG_INFINITY).to_xpath_string(), + "-Infinity" + ); + } + + // -- Number formatting edge cases --------------------------------------- + + #[test] + fn test_format_xpath_number_negative_zero() { + // XPath requires that -0 formats as "0", not "-0" + assert_eq!(format_xpath_number(-0.0), "0"); + } + + #[test] + fn test_format_xpath_number_integers() { + assert_eq!(format_xpath_number(0.0), "0"); + assert_eq!(format_xpath_number(1.0), "1"); + assert_eq!(format_xpath_number(-1.0), "-1"); + assert_eq!(format_xpath_number(100.0), "100"); + assert_eq!(format_xpath_number(999_999.0), "999999"); + } + + #[test] + fn test_format_xpath_number_fractional() { + assert_eq!(format_xpath_number(1.5), "1.5"); + assert_eq!(format_xpath_number(0.1), "0.1"); + assert_eq!(format_xpath_number(-2.75), "-2.75"); + } + + #[test] + fn test_format_xpath_number_special_values() { + assert_eq!(format_xpath_number(f64::NAN), "NaN"); + assert_eq!(format_xpath_number(f64::INFINITY), "Infinity"); + assert_eq!(format_xpath_number(f64::NEG_INFINITY), "-Infinity"); + } + + // -- as_node_set -------------------------------------------------------- + + #[test] + fn test_as_node_set() { + let mut doc = Document::new(); + let id = make_node_id_in_doc(&mut doc); + let ns = XPathValue::NodeSet(vec![id.into()]); + assert!(ns.as_node_set().is_some()); + assert_eq!(ns.as_node_set().unwrap().len(), 1); + + assert!(XPathValue::Boolean(true).as_node_set().is_none()); + assert!(XPathValue::Number(1.0).as_node_set().is_none()); + assert!(XPathValue::String("x".to_owned()).as_node_set().is_none()); + } + + // -- type_name ---------------------------------------------------------- + + #[test] + fn test_type_name() { + assert_eq!(XPathValue::Boolean(true).type_name(), "boolean"); + assert_eq!(XPathValue::Number(0.0).type_name(), "number"); + assert_eq!(XPathValue::String(String::new()).type_name(), "string"); + assert_eq!(XPathValue::NodeSet(vec![]).type_name(), "node-set"); + } + + // -- Display ------------------------------------------------------------ + + #[test] + fn test_display() { + let mut doc = Document::new(); + let id1 = make_node_id_in_doc(&mut doc); + let id2 = make_node_id_in_doc(&mut doc); + + assert_eq!(XPathValue::Boolean(true).to_string(), "true"); + assert_eq!(XPathValue::Boolean(false).to_string(), "false"); + assert_eq!(XPathValue::Number(42.0).to_string(), "42"); + assert_eq!(XPathValue::String("hi".to_owned()).to_string(), "hi"); + assert_eq!( + XPathValue::NodeSet(vec![id1.into(), id2.into()]).to_string(), + "<node-set of 2 nodes>" + ); + } + + // -- PartialEq ---------------------------------------------------------- + + #[test] + fn test_partial_eq() { + assert_eq!(XPathValue::Boolean(true), XPathValue::Boolean(true)); + assert_ne!(XPathValue::Boolean(true), XPathValue::Boolean(false)); + assert_eq!(XPathValue::Number(1.0), XPathValue::Number(1.0)); + // NaN != NaN + assert_ne!(XPathValue::Number(f64::NAN), XPathValue::Number(f64::NAN)); + assert_eq!( + XPathValue::String("a".to_owned()), + XPathValue::String("a".to_owned()) + ); + // Different variants are never equal via PartialEq + assert_ne!(XPathValue::Boolean(true), XPathValue::Number(1.0)); + } + + // -- Comparison helpers ------------------------------------------------- + + #[test] + fn test_compare_values_eq_booleans() { + let t = XPathValue::Boolean(true); + let f = XPathValue::Boolean(false); + assert_eq!(compare_values_eq(&t, &t), Some(true)); + assert_eq!(compare_values_eq(&t, &f), Some(false)); + } + + #[test] + fn test_compare_values_eq_boolean_coercion() { + // When one operand is boolean, the other is converted to boolean + let t = XPathValue::Boolean(true); + let num = XPathValue::Number(42.0); // to_boolean() -> true + assert_eq!(compare_values_eq(&t, &num), Some(true)); + + let f = XPathValue::Boolean(false); + let zero = XPathValue::Number(0.0); // to_boolean() -> false + assert_eq!(compare_values_eq(&f, &zero), Some(true)); + } + + #[test] + fn test_compare_values_eq_number_and_string() { + // When one is number and other is string, convert string to number + let num = XPathValue::Number(42.0); + let s = XPathValue::String("42".to_owned()); + assert_eq!(compare_values_eq(&num, &s), Some(true)); + + let bad = XPathValue::String("abc".to_owned()); + assert_eq!(compare_values_eq(&num, &bad), Some(false)); + } + + #[test] + fn test_compare_values_eq_strings() { + let a = XPathValue::String("hello".to_owned()); + let b = XPathValue::String("hello".to_owned()); + let c = XPathValue::String("world".to_owned()); + assert_eq!(compare_values_eq(&a, &b), Some(true)); + assert_eq!(compare_values_eq(&a, &c), Some(false)); + } + + #[test] + fn test_compare_values_eq_with_nodeset_returns_none() { + let mut doc = Document::new(); + let id = make_node_id_in_doc(&mut doc); + let ns = XPathValue::NodeSet(vec![id.into()]); + let s = XPathValue::String("x".to_owned()); + assert_eq!(compare_values_eq(&ns, &s), None); + assert_eq!(compare_values_eq(&s, &ns), None); + } + + #[test] + fn test_compare_with_string_values_nodeset_to_string() { + let mut doc = Document::new(); + let id1 = make_node_id_in_doc(&mut doc); + let id2 = make_node_id_in_doc(&mut doc); + let ns = XPathValue::NodeSet(vec![id1.into(), id2.into()]); + let s = XPathValue::String("hello".to_owned()); + let node_strings = vec!["world".to_owned(), "hello".to_owned()]; + + assert!(compare_values_with_string_values( + &ns, + &s, + &node_strings, + &[] + )); + } + + #[test] + fn test_compare_with_string_values_nodeset_to_number() { + let mut doc = Document::new(); + let id1 = make_node_id_in_doc(&mut doc); + let id2 = make_node_id_in_doc(&mut doc); + let ns = XPathValue::NodeSet(vec![id1.into(), id2.into()]); + let n = XPathValue::Number(42.0); + let node_strings = vec!["10".to_owned(), "42".to_owned()]; + + assert!(compare_values_with_string_values( + &ns, + &n, + &node_strings, + &[] + )); + } + + #[test] + fn test_compare_with_string_values_nodeset_to_boolean() { + let mut doc = Document::new(); + let id = make_node_id_in_doc(&mut doc); + let ns = XPathValue::NodeSet(vec![id.into()]); + let b = XPathValue::Boolean(true); + + assert!(compare_values_with_string_values(&ns, &b, &[], &[])); + + let empty_ns = XPathValue::NodeSet(vec![]); + assert!(!compare_values_with_string_values(&empty_ns, &b, &[], &[])); + } + + #[test] + fn test_compare_with_string_values_nodeset_to_nodeset() { + let mut doc = Document::new(); + let id1 = make_node_id_in_doc(&mut doc); + let id2 = make_node_id_in_doc(&mut doc); + let ns1 = XPathValue::NodeSet(vec![id1.into()]); + let ns2 = XPathValue::NodeSet(vec![id2.into()]); + + let strings1 = vec!["hello".to_owned()]; + let strings2 = vec!["hello".to_owned()]; + + assert!(compare_values_with_string_values( + &ns1, &ns2, &strings1, &strings2, + )); + + let strings2_diff = vec!["world".to_owned()]; + assert!(!compare_values_with_string_values( + &ns1, + &ns2, + &strings1, + &strings2_diff, + )); + } + + // -- XPathError Display ------------------------------------------------- + + #[test] + fn test_xpath_error_display_type_error() { + let err = XPathError::TypeError { + expected: "node-set".to_owned(), + found: "string".to_owned(), + }; + assert_eq!( + err.to_string(), + "type error: expected node-set, found string" + ); + } + + #[test] + fn test_xpath_error_display_undefined_variable() { + let err = XPathError::UndefinedVariable { + name: "foo".to_owned(), + }; + assert_eq!(err.to_string(), "undefined variable: $foo"); + } + + #[test] + fn test_xpath_error_display_undefined_function() { + let err = XPathError::UndefinedFunction { + name: "my-func".to_owned(), + }; + assert_eq!(err.to_string(), "undefined function: my-func()"); + } + + #[test] + fn test_xpath_error_display_invalid_arg_count() { + let err = XPathError::InvalidArgCount { + function: "substring".to_owned(), + expected: 2, + found: 5, + }; + assert_eq!( + err.to_string(), + "invalid argument count for substring(): expected 2, found 5" + ); + } + + #[test] + fn test_xpath_error_display_invalid_expression() { + let err = XPathError::InvalidExpression { + message: "unexpected token ')'".to_owned(), + }; + assert_eq!( + err.to_string(), + "invalid XPath expression: unexpected token ')'" + ); + } + + #[test] + fn test_xpath_error_display_internal_error() { + let err = XPathError::InternalError { + message: "stack overflow".to_owned(), + }; + assert_eq!(err.to_string(), "internal XPath error: stack overflow"); + } + + #[test] + fn test_xpath_error_is_error_trait() { + let err = XPathError::TypeError { + expected: "boolean".to_owned(), + found: "number".to_owned(), + }; + let _: &dyn std::error::Error = &err; + } + + // -- to_string_with_string_value ---------------------------------------- + + #[test] + fn test_to_string_with_string_value_nodeset() { + let mut doc = Document::new(); + let id = make_node_id_in_doc(&mut doc); + let val = XPathValue::NodeSet(vec![id.into()]); + assert_eq!(val.to_string_with_string_value(Some("hello")), "hello"); + assert_eq!(val.to_string_with_string_value(None), ""); + } + + #[test] + fn test_to_string_with_string_value_non_nodeset() { + let val = XPathValue::Number(42.0); + // The string value parameter is ignored for non-node-set values + assert_eq!(val.to_string_with_string_value(Some("ignored")), "42"); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cabd5e79..3f7d56fd2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,15 @@ importers: esbuild: specifier: ^0.25.0 version: 0.25.12 + react: + specifier: ^19.2.6 + version: 19.2.7 typescript: specifier: ^5.9.2 version: 5.9.3 + vitest: + specifier: ^4.1.6 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@25.9.5)(jiti@2.7.0)) canvas/ui: dependencies: From 37f038f7c526475a6c9d744f91cef7b1c885ef6d Mon Sep 17 00:00:00 2001 From: Anderson Leal <andersonofl@gmail.com> Date: Wed, 19 Aug 2026 12:36:50 -0300 Subject: [PATCH 2/8] test(browser): cover every wire-observable hardening feature in the e2e suite Adds 18 e2e cases (27 -> 45) so each hardening fix from the scrapling-native pass is exercised over the real bus: SSRF v6-embedded-v4 forms, egress-gate Connection forcing + 403 denial page, max_redirects refusal/clamp, redirect Set-Cookie replay with hostname scoping, repeated-header flattening, duration clamps, compat-only and real_chrome refusals, the Cloudflare solve deadline, dedicated-worker resume (child-target routing), limit coercion, describe kind:null, PI stripping, markdownify parity, session pending-slot rollback, the inject_guidance hot-apply flip, safe-mode config-proxy omission, and crawl IDN normalization. The harness now registers the harness::hook::pre-generate trigger type (as a real agent-harness stack would) so the guidance binding activates out of the engine's pending map and its bind/unbind is observable through engine::registered-triggers::list. The local origin server gains redirect, multi-cookie, header-echo, fake-Cloudflare, dedicated-worker, and IDN-link endpoints; the three configuration-mutating cases restore the browser entry in finally. Features with no wire-observable behavior stay unit-test-only and are documented in the cases-hardening.ts header. Default sentinel timeout raised to 240s for the added browser-tier cases. --- browser/tests/e2e/README.md | 18 +- browser/tests/e2e/run-tests.sh | 4 +- .../workers/harness/src/cases-hardening.ts | 550 ++++++++++++++++++ .../tests/e2e/workers/harness/src/runner.ts | 86 ++- 4 files changed, 649 insertions(+), 9 deletions(-) create mode 100644 browser/tests/e2e/workers/harness/src/cases-hardening.ts diff --git a/browser/tests/e2e/README.md b/browser/tests/e2e/README.md index 035dd3bbc..c9b0883f4 100644 --- a/browser/tests/e2e/README.md +++ b/browser/tests/e2e/README.md @@ -111,18 +111,23 @@ overridden: | `run-tests.sh` | Orchestrator | | `config.yaml` | Engine infra only (worker-manager port override + observability) | | `workers/harness/` | TypeScript smoke-test worker (runs as a host process) | -| `workers/harness/src/cases.ts` | All 27 cases; parse expectations come from `../../../../tests/golden/behavior/**`, while outbound cases exercise hermetic validation/state paths | +| `workers/harness/src/cases.ts` | The 27 base cases; parse expectations come from `../../../../tests/golden/behavior/**`, while outbound cases exercise hermetic validation/state paths | +| `workers/harness/src/cases-hardening.ts` | 18 hardening-feature cases (SSRF v6 embeds, egress gate, redirects/cookies, safe-mode policy, CDP child targets, parse parity fixes, session capacity, config hot-apply, crawl IDN); its header documents the internals that are unit-test-only by necessity | | `workers/harness/src/runner.ts` | Runs the cases, records pass/fail, writes `reports/report.json` | | `workers/harness/src/worker.ts` | Entry point; registers with the bus, emits the `HARNESS_DONE` sentinel | | `reports/report.json` | Per-case results (latest run) | ## Cases -The suite currently contains 27 cases: ten parse-function examples, adaptive -and XPath compatibility, limit/error cases, outbound security policy, crawl -validation, and a private HTTP-session lifecycle. The authoritative names and -assertions live in `workers/harness/src/cases.ts`; keep this summary grouped so -it does not become a second manually numbered source of truth. +The suite currently contains 45 cases: 27 base cases (ten parse-function +examples, adaptive and XPath compatibility, limit/error cases, outbound +security policy, crawl validation, and a private HTTP-session lifecycle) plus +18 hardening-feature cases in `cases-hardening.ts` (one per wire-observable +fix from the hardening pass, including three configuration-mutating cases that +restore the `browser` configuration entry in `finally`). The authoritative +names and assertions live in `workers/harness/src/cases.ts` and +`cases-hardening.ts`; keep this summary grouped so it does not become a second +manually numbered source of truth. | # | Case | Asserts | |---|---|---| @@ -141,6 +146,7 @@ it does not become a second manually numbered source of truth. | 13 | `xpath` ancestor axis | reverse-axis positional semantics | | 14 | `find` limit 0 | items clamp to `[]`; `count` stays the true (pre-cap) total | | 15–27 | outbound/browser/session | SSRF and safe-policy errors, dynamic/stealthy rendering, screenshot wire shape, crawl delivery, HTTP cookie state, UUID session metadata, persistent browser sessions, foreign-id rejection | +| 28–45 | hardening features | v6-embedded-v4 SSRF forms, egress-gate header forcing + 403 page, redirect clamp/refusal, hop cookie replay + hostname scoping, header flattening, duration clamps, compat-only/`real_chrome` refusals, Cloudflare solve deadline, dedicated-worker resume, `limit` coercion, `describe kind:null`, PI stripping, markdownify parity, session pending-slot rollback, `inject_guidance` hot-apply, safe-mode config-proxy omission, crawl IDN normalization | ## CI diff --git a/browser/tests/e2e/run-tests.sh b/browser/tests/e2e/run-tests.sh index 1906e439f..21a36bb0c 100755 --- a/browser/tests/e2e/run-tests.sh +++ b/browser/tests/e2e/run-tests.sh @@ -33,7 +33,7 @@ TS=$(date +%Y%m%d-%H%M%S) ENGINE_LOG="$ROOT_DIR/reports/engine-$TS.log" WORKER_LOG="$ROOT_DIR/reports/browser-$TS.log" HARNESS_LOG="$ROOT_DIR/reports/harness-$TS.log" -SENTINEL_TIMEOUT="${HARNESS_TIMEOUT:-120}" +SENTINEL_TIMEOUT="${HARNESS_TIMEOUT:-240}" KEEP=0 NO_BUILD=0 @@ -62,7 +62,7 @@ Env overrides: WORKER_SRC Path to the browser crate (default: ../..). III_BIN Path to the iii engine binary (default: \$(command -v iii) or \$HOME/.local/bin/iii). WORKER_BIN_TARGET Path to the built worker binary (default: \$WORKER_SRC/target/release/browser). - HARNESS_TIMEOUT Seconds to wait for the harness sentinel (default: 120). + HARNESS_TIMEOUT Seconds to wait for the harness sentinel (default: 240). EOF exit 0 ;; diff --git a/browser/tests/e2e/workers/harness/src/cases-hardening.ts b/browser/tests/e2e/workers/harness/src/cases-hardening.ts new file mode 100644 index 000000000..42ea6ebf0 --- /dev/null +++ b/browser/tests/e2e/workers/harness/src/cases-hardening.ts @@ -0,0 +1,550 @@ +/** + * E2E coverage for the scrapling-native hardening pass: one case per feature + * that is observable over the iii bus. Expected error strings are pinned + * full-length against the live worker (expectError is full-string equality). + * + * Deliberately NOT covered here — no wire-observable behavior exists, each is + * certified by unit tests in the worker crate instead: + * - chromiumoxide log filter (src/logging.rs): needs protocol-skew frames the + * frozen CI Chromium may never emit + * - CDP per-command 180s ceiling and Lagged-recoverable/broadcast sizing + * (src/scrapling/cdp.rs): internal, and 180s exceeds the suite budget + * - retry-delay 60s cap timing (raw_browser.rs): only a timing side channel + * - target-close-on-error leak fixes (raw_browser.rs): no external counter + * - sessions insertion_order pruning (sessions.rs): filtered out of + * session-list; the capacity path it protects is covered below + * - OOPIF iframe HTML: main-frame-only serialization by design, and same-host + * ports are same-site so no OOPIF forms locally; the route_child_targets + * fix is covered via the dedicated-Web-Worker case instead + */ + +import { expect, expectEqual, expectError, type CaseContext, type TestCase } from './cases.ts' + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +type Call = CaseContext['call'] + +async function getBrowserConfig(call: Call): Promise<any> { + const got = await call('configuration::get', { id: 'browser' }) + return got.value +} + +async function setBrowserConfig(call: Call, value: unknown): Promise<void> { + await call('configuration::set', { id: 'browser', value }) +} + +async function guidanceTriggerVisible(call: Call): Promise<boolean> { + const listed = await call('engine::registered-triggers::list', { include_internal: true }) + return (listed.registered_triggers ?? []).some( + (t: any) => + t.trigger_type === 'harness::hook::pre-generate' && t.function_id === 'browser::inject-guidance', + ) +} + +async function pollUntil(cond: () => Promise<boolean>, label: string, timeoutMs = 10_000): Promise<void> { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (await cond()) return + await sleep(200) + } + throw new Error(`timed out waiting for ${label}`) +} + +// Five matches for the limit-coercion case; count must stay the pre-cap total. +const LIMIT_HTML = '<html><body><ul><li>a</li><li>b</li><li>c</li><li>d</li><li>e</li></ul></body></html>' + +export const HARDENING_CASES: TestCase[] = [ + { + // NAT64/6to4/IPv4-compatible IPv6 literals embed a v4 address that must + // run the v4 blocklist. Loopback embeds are NOT used here: this stack + // sets allow_loopback=true, which legitimately admits them. + name: 'ssrf: v6-embedded-v4 forms run the v4 blocklist', + async run({ call }) { + await expectError( + () => call('browser::fetch', { url: 'http://[64:ff9b::a9fe:a9fe]/' }), + 'handler error: address 64:ff9b::a9fe:a9fe is in link-local (incl. AWS metadata)', + 'nat64 well-known prefix embedding the metadata address', + ) + await expectError( + () => call('browser::fetch', { url: 'http://[64:ff9b:1::1]/' }), + 'handler error: address 64:ff9b:1::1 is in local-use nat64 64:ff9b:1::/48', + 'local-use nat64 prefix is refused wholesale', + ) + await expectError( + () => call('browser::fetch', { url: 'http://[2002:a00:1::]/' }), + 'handler error: address 2002:a00:1:: is in private rfc1918', + '6to4 prefix embedding an rfc1918 address', + ) + }, + }, + { + name: 'egress gate: forces Connection: close and denies blocked addresses with a 403 page', + async run({ call, origin }) { + const echoed = await call('browser::dynamic-fetch', { + url: `${origin}/echo-headers`, + include_html: true, + retries: 1, + timeout: 8000, + }) + expectEqual(echoed.status, 200, 'echo-headers status') + const connections = echoed.html.match(/"connection"/gi) ?? [] + expectEqual(connections.length, 1, 'exactly one Connection header reaches the origin') + expect(/"connection","close"/i.test(echoed.html), 'the forwarded Connection header is close') + expect(!/proxy-connection/i.test(echoed.html), 'Proxy-Connection must be stripped') + expect(!/keep-alive/i.test(echoed.html), 'keep-alive must be stripped') + expect( + echoed.html.includes('"url":"/echo-headers"'), + 'the gate re-emits the absolute-form proxy request in origin-form', + ) + + // The gate is the whole SSRF defense for browser tiers: a blocked + // address is not a worker error but a 403 page Chromium renders. + const denied = await call('browser::dynamic-fetch', { + url: 'http://169.254.169.254/', + include_html: true, + retries: 1, + timeout: 8000, + }) + expectEqual(denied.status, 403, 'gate denial status') + expect( + denied.html.includes( + 'browser egress denied: address 169.254.169.254 is in link-local (incl. AWS metadata)', + ), + `gate denial body missing: ${denied.html}`, + ) + }, + }, + { + name: 'fetch: max_redirects refuses -1 in safe mode and clamps large values to 100', + async run({ call, origin }) { + await expectError( + () => call('browser::fetch', { url: `${origin}/loop`, max_redirects: -1 }), + 'handler error: safe mode refuses `max_redirects:-1`: unlimited or invalid redirect counts exceed the bounded request policy; use a non-negative limit', + 'unlimited redirects refused', + ) + await expectError( + () => call('browser::fetch', { url: `${origin}/loop`, max_redirects: 5000, retries: 1 }), + `handler error: too many redirects (>100) starting at ${origin}/loop`, + 'redirect budget clamps to 100, not 5000', + ) + const halted = await call('browser::fetch', { url: `${origin}/loop`, follow_redirects: false, retries: 1 }) + expectEqual(halted.status, 302, 'follow_redirects:false returns the redirect itself') + }, + }, + { + name: 'fetch: redirect Set-Cookie replays on same-host hops only', + async run({ call, origin }) { + const hopped = await call('browser::fetch', { url: `${origin}/hop-a`, include_html: true, retries: 1 }) + expectEqual(hopped.status, 200, 'hop status') + expectEqual(hopped.url, `${origin}/hop-b`, 'hop final url') + expect(hopped.html.includes('<p>hop=1</p>'), `redirect target did not receive the hop cookie: ${hopped.html}`) + expectEqual(hopped.cookies, { hop: '1' }, 'envelope accumulates hop cookies') + + // 127.0.0.1 -> localhost is the same server but a different hostname: + // the Cookie header must not replay, while the envelope accumulator + // (deliberately global across hops) still reports it. + const scoped = await call('browser::fetch', { url: `${origin}/hop-x`, include_html: true, retries: 1 }) + expect(scoped.html.includes('<p>none</p>'), `cross-hostname hop leaked the cookie: ${scoped.html}`) + expectEqual(scoped.cookies, { hopx: '1' }, 'accumulator still reports every hop cookie') + }, + }, + { + name: 'fetch: repeated response headers flatten with a comma join', + async run({ call, origin }) { + const r = await call('browser::fetch', { url: `${origin}/multi-cookie`, retries: 1 }) + expectEqual(r.headers['set-cookie'], 'a=1; Path=/, b=2; Path=/', 'set-cookie flattened') + expectEqual(r.headers['x-test'], 'one, two', 'x-test flattened') + expectEqual(r.cookies, { a: '1', b: '2' }, 'cookie map splits each set-cookie') + }, + }, + { + name: 'browser tiers: absurd durations clamp or refuse instead of panicking', + async run({ call, origin }) { + await expectError( + () => call('browser::dynamic-fetch', { url: `${origin}/page`, timeout: -1 }), + 'handler error: Invalid argument type: Expected `float` >= 0.0 - at `$.timeout`', + 'negative timeout refused with the Python-parity message', + ) + // Unclamped, Duration::from_secs_f64(1e300) panics inside the handler. + const huge = await call('browser::dynamic-fetch', { + url: `${origin}/page`, + retry_delay: 1e300, + retries: 1, + timeout: 8000, + include_html: true, + }) + expectEqual(huge.status, 200, 'absurd retry_delay clamps instead of panicking') + }, + }, + { + name: 'safe mode: compat-only options are refused with the full policy message', + async run({ call, origin }) { + const url = `${origin}/page` + const httpRefusals: Array<[Record<string, unknown>, string, string]> = [ + [ + { http3: true }, + 'handler error: safe mode refuses `http3`: the safe reqwest engine has no certified HTTP/3 transport; use a certified compat build or remove the option', + 'http3', + ], + [ + { verify: false }, + 'handler error: safe mode refuses `verify:false`: TLS certificate verification cannot be disabled; use a certified compat build or remove the option', + 'verify:false', + ], + [ + { proxies: { https: 'http://127.0.0.1:9/' } }, + 'handler error: safe mode refuses `proxies`: per-scheme proxies bypass address pinning; use a certified compat build or remove the option', + 'proxies', + ], + [ + { proxy_auth: { username: 'u', password: 'p' } }, + 'handler error: safe mode refuses `proxy_auth`: proxy authentication is unavailable when caller proxies are refused; use a certified compat build or remove the option', + 'proxy_auth', + ], + [ + { stealthy_headers: true }, + "handler error: safe mode refuses `stealthy_headers:true`: the safe reqwest engine cannot reproduce BrowserForge's generated header fingerprint; use a certified compat build or remove the option", + 'stealthy_headers:true', + ], + [ + { impersonate: 'firefox' }, + 'handler error: safe mode refuses `impersonate`: the safe engine implements only its bounded Chrome header profile; use a certified compat build or remove the option', + 'impersonate', + ], + ] + for (const [option, expected, label] of httpRefusals) { + await expectError(() => call('browser::fetch', { url, ...option }), expected, `fetch ${label}`) + } + await expectError( + () => call('browser::dynamic-fetch', { url, dns_over_https: true }), + 'handler error: dns_over_https require browser.scrapling.security_mode=compat', + 'dynamic-fetch dns_over_https', + ) + await expectError( + () => call('browser::session-open', { type: 'http', http3: true }), + 'handler error: session options require browser.scrapling.security_mode=compat; remove them or switch modes', + 'session-open compat-only option', + ) + }, + }, + { + name: 'safe mode: real_chrome is refused on every browser tier', + async run({ call, origin }) { + const url = `${origin}/page` + for (const functionId of ['browser::dynamic-fetch', 'browser::stealthy-fetch', 'browser::screenshot-url']) { + await expectError( + () => call(functionId, { url, real_chrome: true }), + 'handler error: real_chrome require browser.scrapling.security_mode=compat', + `${functionId} real_chrome`, + ) + } + await expectError( + () => call('browser::session-open', { type: 'dynamic', real_chrome: true }), + 'handler error: session options require browser.scrapling.security_mode=compat; remove them or switch modes', + 'session-open real_chrome', + ) + // Crawl validates per page inside the fetch closure, so the refusal is + // an inline item error (unprefixed), not a top-level rejection. + const crawled = await call('browser::crawl', { + url, + fetcher: 'dynamic', + real_chrome: true, + max_pages: 1, + max_depth: 0, + concurrency: 1, + }) + expectEqual(crawled.stats.errors, 1, 'crawl real_chrome error count') + expectEqual( + crawled.items?.[0]?.error, + 'real_chrome require browser.scrapling.security_mode=compat', + 'crawl real_chrome inline item error', + ) + }, + }, + { + name: 'dynamic-fetch: dedicated worker resumes past waitForDebuggerOnStart', + async run({ call, origin }) { + // Child targets attach frozen (waitForDebuggerOnStart). Without the + // child-target routing task the worker never runs and #out stays + // "pending" forever. + const r = await call('browser::dynamic-fetch', { + url: `${origin}/worker-page`, + include_html: true, + wait: 1500, + timeout: 10000, + retries: 1, + }) + expectEqual(r.status, 200, 'worker-page status') + expect(r.html.includes('worker-ran'), `dedicated worker never resumed: ${r.html}`) + }, + }, + { + name: 'find: limit coerces floats and numeric strings, clamps negatives', + async run({ call }) { + const probe = async (limit: unknown) => + await call('browser::find', { html: LIMIT_HTML, tag: 'li', limit }) + const float = await probe(2.5) + expectEqual(float.count, 5, 'count stays the pre-cap total') + expectEqual(float.items.length, 2, 'float limit truncates toward zero') + expectEqual((await probe('3')).items.length, 3, 'numeric string limit parses') + expectEqual((await probe(' 2 ')).items.length, 2, 'whitespace-padded string limit parses') + expectEqual((await probe(-1)).items.length, 0, 'negative limit clamps to zero') + expectEqual((await probe(true)).items.length, 5, 'uncoercible limit falls back to the 100 cap') + const first = await call('browser::find', { html: LIMIT_HTML, tag: 'li', limit: 5, first: true }) + expectEqual(first.items.length, 1, 'first:true short-circuits any limit') + }, + }, + { + name: 'describe: omitted kind is CSS, explicit null is XPath (Python parity)', + async run({ call }) { + // Omitted -> CSS branch: an XPath query is an invalid CSS selector. + await expectError( + () => call('browser::describe', { html: '<p>x</p>', query: '//p' }), + "handler error: Invalid CSS selector '//p': Expected selector, got <DELIM '/' at 0>", + 'omitted kind takes the CSS branch', + ) + // Explicit null -> XPath branch (Python: payload.get("kind", "css"), + // where None != "css"). + const r = await call('browser::describe', { html: '<p>x</p>', query: '//p', kind: null }) + expectEqual(r.found, true, 'kind:null takes the XPath branch') + expectEqual(r.element?.tag, 'p', 'xpath describe element') + }, + }, + { + name: 'parse: processing instructions are stripped from the tree', + async run({ call }) { + expectEqual( + await call('browser::to-markdown', { + html: "<html><body><?php echo 'LEAK'; ?><p>hi</p></body></html>", + format: 'text', + }), + { format: 'text', content: 'hi' }, + 'PI stripped from text rendering', + ) + expectEqual( + await call('browser::css', { html: '<div><?pi LEAK?>text</div>', query: 'div', first: true }), + { result: 'text' }, + 'PI stripped from css text extraction', + ) + }, + }, + { + name: 'to-markdown: whitespace, empty link attrs, list spacing, ol start', + async run({ call }) { + const md = async (html: string) => + (await call('browser::to-markdown', { html, format: 'markdown' })).content + // ASCII runs collapse; NBSP survives (split_whitespace would eat it). + expectEqual(await md('<h3>x\u00a0\u00a0y \n z</h3>'), '### x\u00a0\u00a0y z', 'heading collapse keeps NBSP') + const dt = await md('<dl><dt>Term \n Name\u00a0X</dt><dd>Def</dd></dl>') + expect(dt.includes('Term Name\u00a0X'), `dt collapse lost the NBSP or kept the run: ${JSON.stringify(dt)}`) + // Empty href/title filter to None instead of producing link markup. + expectEqual(await md('<p><a href="" title="">text</a></p>'), 'text', 'empty href drops link markup') + expectEqual(await md('<p><a href="http://x/" title="">http://x/</a></p>'), '<http://x/>', 'empty title keeps the autolink form') + // A bare text sibling after a list still forces the blank line. + expectEqual(await md('<html><body><ul><li>a</li></ul>tail</body></html>'), '* a\n\ntail', 'list before bare text keeps the blank line') + // ol start: ASCII digits only; anything else falls back to 1. + expectEqual(await md('<ol start="١"><li>a</li></ol>'), '1. a', 'non-ASCII ol start falls back to 1') + expectEqual(await md('<ol start="3"><li>a</li><li>b</li></ol>'), '3. a\n4. b', 'ASCII ol start numbers from it') + }, + }, + { + name: 'session: failed opens roll back their pending slot', + async run({ call }) { + // These fail INSIDE the constructor, after the pending counter was + // reserved — exactly the path the drop guard protects. Without the + // rollback, three failures would eat 3 of the 8 slots forever. + for (let i = 0; i < 3; i++) { + await expectError( + () => call('browser::session-open', { type: 'dynamic', wait_selector_state: 'bogus' }), + "handler error: Invalid argument type: Invalid enum value 'bogus' - at `$.wait_selector_state`", + `failing open ${i + 1}`, + ) + } + const opened: string[] = [] + try { + for (let i = 0; i < 8; i++) { + const r = await call('browser::session-open', { type: 'http' }) + opened.push(r.session_id) + } + await expectError( + () => call('browser::session-open', { type: 'http' }), + 'handler error: session limit reached (8); close one first', + 'capacity intact after failed opens', + ) + } finally { + for (const id of opened) await call('browser::session-close', { session_id: id }) + } + // Closing freed the slots again. + const again = await call('browser::session-open', { type: 'http' }) + expectEqual((await call('browser::session-close', { session_id: again.session_id })).closed, true, 'slot reusable after close') + }, + }, + + // ---- configuration-mutating cases: keep these LAST, restore in finally --- + { + name: 'inject_guidance: config flip binds and unbinds the pre-generate hook live', + async run({ call }) { + // The runner registers the harness::hook::pre-generate trigger type, so + // the worker's boot-time binding activates from the engine's pending map + // (asynchronously — hence a poll, not a one-shot check). + await pollUntil( + () => guidanceTriggerVisible(call), + 'the boot-time guidance binding to activate (inject_guidance defaults true)', + ) + const original = await getBrowserConfig(call) + try { + const off = structuredClone(original) + off.scrapling = { ...off.scrapling, inject_guidance: false } + await setBrowserConfig(call, off) + await pollUntil( + async () => !(await guidanceTriggerVisible(call)), + 'guidance trigger to unbind after inject_guidance=false', + ) + const on = structuredClone(original) + on.scrapling = { ...on.scrapling, inject_guidance: true } + await setBrowserConfig(call, on) + await pollUntil( + () => guidanceTriggerVisible(call), + 'guidance trigger to rebind after inject_guidance=true', + ) + } finally { + await setBrowserConfig(call, original) + } + // The hook itself: non-empty base appends the guidance, empty base + // returns no mutation (preserves the harness prompt). + const hooked = await call('browser::inject-guidance', { generate: { system_prompt: 'BASE' } }) + expect( + typeof hooked.mutations?.system_prompt === 'string' && + hooked.mutations.system_prompt.startsWith('BASE\n\n## Scraping and HTML parsing (browser::*)'), + `guidance hook did not append: ${JSON.stringify(hooked)}`, + ) + expectEqual( + await call('browser::inject-guidance', { generate: { system_prompt: '' } }), + { mutations: {} }, + 'empty base preserves the harness prompt', + ) + }, + }, + { + name: 'solve_cloudflare: the solve loop is bounded by the configured deadline', + async run({ call, origin }) { + const original = await getBrowserConfig(call) + try { + const lowered = structuredClone(original) + lowered.max_timeout_ms = 3000 + await setBrowserConfig(call, lowered) + await pollUntil( + async () => (await getBrowserConfig(call)).max_timeout_ms === 3000, + 'the store to show the lowered timeout cap', + ) + // configuration:updated reaches the worker asynchronously; if the + // first attempt raced the reload it burns the SDK's 30s ceiling with + // a different error, so allow one retry. + await sleep(1500) + let attempt = 0 + for (;;) { + const start = Date.now() + try { + await expectError( + () => + call('browser::stealthy-fetch', { + url: `${origin}/cf-managed`, + solve_cloudflare: true, + include_html: true, + retries: 1, + }), + 'handler error: timed out solving the Cloudflare challenge', + 'unsolvable challenge times out', + ) + expect( + Date.now() - start < 20_000, + 'the solve deadline followed the lowered max_timeout_ms (3s), not the 60s floor', + ) + break + } catch (e) { + if (++attempt >= 2) throw e + } + } + // Control: same marker, no spin text — the solve loop falls through. + const clean = await call('browser::stealthy-fetch', { + url: `${origin}/cf-clean`, + solve_cloudflare: true, + include_html: true, + retries: 1, + }) + expectEqual(clean.status, 200, 'clean page passes the solve loop') + expect(clean.html.includes('<p>done</p>'), `clean page html: ${clean.html}`) + } finally { + await setBrowserConfig(call, original) + } + }, + }, + { + name: 'safe mode: the config-default proxy is not injected into requests', + async run({ call, origin }) { + const original = await getBrowserConfig(call) + try { + const withProxy = structuredClone(original) + withProxy.scrapling = { + ...withProxy.scrapling, + defaults: { ...withProxy.scrapling.defaults, proxy: 'http://127.0.0.1:9/' }, + } + await setBrowserConfig(call, withProxy) + await pollUntil( + async () => (await getBrowserConfig(call)).scrapling?.defaults?.proxy === 'http://127.0.0.1:9/', + 'the store to show the default proxy', + ) + // Safe mode makes the omission itself invisible by design (that IS the + // fix), so the barrier is store-side plus a settle delay: pre-fix, + // both calls below failed against the dead proxy port. + await sleep(1500) + const http = await call('browser::fetch', { url: `${origin}/page`, retries: 1 }) + expectEqual(http.status, 200, 'safe fetch ignores the config-default proxy') + const dyn = await call('browser::dynamic-fetch', { url: `${origin}/page`, retries: 1, timeout: 8000 }) + expectEqual(dyn.status, 200, 'safe dynamic-fetch ignores the config-default proxy') + // The explicit caller refusal must survive the omission. + await expectError( + () => call('browser::fetch', { url: `${origin}/page`, proxy: 'http://127.0.0.1:9/' }), + 'handler error: safe mode refuses `proxy`: a caller proxy can resolve or route to addresses outside the egress policy; use a certified compat build or remove the option', + 'explicit caller proxy still refused', + ) + } finally { + await setBrowserConfig(call, original) + } + }, + }, + { + name: 'crawl: allowed_domains are punycode-normalized before matching (IDN)', + async run({ call, origin }) { + // The /idn page links to the punycode host; the unicode allow-list only + // admits it if both sides normalize to xn--mnchen-3ya.example. Admitted + // means crawled (and failing DNS = an error item); filtered means the + // frontier never grows. + const strict = await call('browser::crawl', { + url: `${origin}/idn`, + fetcher: 'http', + allowed_domains: ['münchen.example'], + max_pages: 5, + max_depth: 1, + concurrency: 1, + timeout: 5, + }) + expectEqual(strict.stats.crawled, 2, 'unicode allow-list admits the punycode link') + expectEqual(strict.stats.errors, 1, 'the admitted link fails to resolve') + const errored = (strict.items ?? []).find((i: any) => i.url === 'http://xn--mnchen-3ya.example/') + expect(Boolean(errored?.error), `expected an inline error item: ${JSON.stringify(strict.items)}`) + + const control = await call('browser::crawl', { + url: `${origin}/idn`, + fetcher: 'http', + allowed_domains: ['nomatch.example'], + max_pages: 5, + max_depth: 1, + concurrency: 1, + timeout: 5, + }) + expectEqual(control.stats.crawled, 1, 'non-matching allow-list filters the link') + expectEqual(control.stats.errors, 0, 'filtered links produce no error') + }, + }, +] diff --git a/browser/tests/e2e/workers/harness/src/runner.ts b/browser/tests/e2e/workers/harness/src/runner.ts index 5f1de8185..54eeb7244 100644 --- a/browser/tests/e2e/workers/harness/src/runner.ts +++ b/browser/tests/e2e/workers/harness/src/runner.ts @@ -4,6 +4,9 @@ import { resolve } from 'node:path' import { once } from 'node:events' import type { ISdk } from 'iii-sdk' import { CASES, ORIGIN_PAGE_HTML, type CaseContext, type TestCase } from './cases.ts' +import { HARDENING_CASES } from './cases-hardening.ts' + +const ALL_CASES: TestCase[] = [...CASES, ...HARDENING_CASES] interface CaseResult { case: string @@ -58,8 +61,19 @@ export class Runner { // harness at all, but the harness is also runnable standalone). await this.callWithRetry('browser::css', { html: '<p>x</p>', query: 'p' }) + // Claim the pre-generate hook trigger type, as a real agent-harness stack + // would. Without an owner the browser worker's guidance binding stays + // parked in the engine's pending map, which registered-triggers::list + // does not expose — with one, the binding goes live and the + // inject_guidance hot-apply case can observe it bind and unbind. The + // handler never fires: nothing in this suite emits pre-generate events. + this.opts.iii.registerTriggerType( + { id: 'harness::hook::pre-generate', description: 'E2E stand-in for the agent harness pre-generate hook.' }, + { registerTrigger: async () => {}, unregisterTrigger: async () => {} }, + ) + const server = await this.startOrigin() - const cases = this.opts.filter ? CASES.filter((c) => c.name.includes(this.opts.filter!)) : CASES + const cases = this.opts.filter ? ALL_CASES.filter((c) => c.name.includes(this.opts.filter!)) : ALL_CASES // Stream each case result to stdout as it completes, colored green/red // only when stdout is a TTY — run-tests.sh redirects stdout to a log @@ -93,6 +107,76 @@ export class Runner { private async startOrigin(): Promise<Server> { const server = createServer((req, res) => { const path = new URL(req.url ?? '/', 'http://127.0.0.1').pathname + const address = server.address() + const port = address && typeof address !== 'string' ? address.port : 0 + + // Hardening-case endpoints own their complete response (status, headers, + // body); everything below them keeps the legacy uniform header block so + // the frozen-envelope assertions of the original cases stay byte-stable. + const html = (content: string, headers: Record<string, string | string[]> = {}, contentType = 'text/html; charset=utf-8') => { + const buf = Buffer.from(content) + res.statusCode = 200 + res.setHeader('Content-Type', contentType) + for (const [k, v] of Object.entries(headers)) res.setHeader(k, v) + res.setHeader('Content-Length', buf.length) + res.setHeader('Connection', 'close') + res.end(buf) + } + const redirect = (location: string, headers: Record<string, string | string[]> = {}) => { + res.statusCode = 302 + res.setHeader('Location', location) + for (const [k, v] of Object.entries(headers)) res.setHeader(k, v) + res.setHeader('Content-Length', 0) + res.setHeader('Connection', 'close') + res.end() + } + res.sendDate = false + switch (path) { + case '/loop': + return redirect(`http://127.0.0.1:${port}/loop`) + case '/hop-a': + return redirect(`http://127.0.0.1:${port}/hop-b`, { 'Set-Cookie': 'hop=1; Path=/' }) + case '/hop-x': + // Same server, different hostname: exercises the hostname-only + // scoping of redirect cookie replay (127.0.0.1 vs localhost). + return redirect(`http://localhost:${port}/hop-b`, { 'Set-Cookie': 'hopx=1; Path=/' }) + case '/hop-b': + return html(`<html><body><p>${req.headers.cookie ?? 'none'}</p></body></html>`) + case '/multi-cookie': + return html('<html><body><p>mc</p></body></html>', { + 'Set-Cookie': ['a=1; Path=/', 'b=2; Path=/'], + 'X-Test': ['one', 'two'], + }) + case '/echo-headers': + // rawHeaders preserves duplicates and original casing — the egress + // gate case counts Connection headers, which req.headers would fold. + return html( + `<html><body><pre>${JSON.stringify({ url: req.url, rawHeaders: req.rawHeaders })}</pre></body></html>`, + ) + case '/cf-managed': + // Fake Cloudflare managed challenge: the cType marker routes + // solve_cloudflare into its managed branch, and the "Verifying" + // text keeps it spinning until the solve deadline expires. + return html( + "<html><head><title>E2E CF</title></head><body><script>/* cType: 'managed' */</script><p>Verifying you are human.</p></body></html>", + ) + case '/cf-clean': + // Same marker but no spin text and no challenge iframe: the solve + // loop must fall through and return the page normally. + return html( + "<html><head><title>fine</title></head><body><script>/* cType: 'managed' */</script><p>done</p></body></html>", + ) + case '/worker-page': + return html( + '<html><body><div id="out">pending</div><script>const w=new Worker("/w.js");w.onmessage=(e)=>{document.getElementById("out").textContent=e.data}</script></body></html>', + ) + case '/w.js': + return html("postMessage('worker-ran')", {}, 'application/javascript') + case '/idn': + return html('<html><body><a href="http://xn--mnchen-3ya.example/">m</a></body></html>') + default: + break + } const body = path === '/plain' ? Buffer.from([0x63, 0x61, 0x66, 0xe9]) From 1ff559608b073f79a17e199914bfb3728532140d Mon Sep 17 00:00:00 2001 From: Anderson Leal <andersonofl@gmail.com> Date: Wed, 19 Aug 2026 12:44:23 -0300 Subject: [PATCH 3/8] fix(browser): pin the hop-x scoping case to [::1] instead of localhost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI resolves localhost to ::1 while the origin server listened only on 127.0.0.1, so the hostname-scoping hop failed with a transport error on the runner. The harness now starts an IPv6-loopback twin server and the redirect targets its IP literal directly — no resolver in the path on any machine. --- .../workers/harness/src/cases-hardening.ts | 6 ++-- .../tests/e2e/workers/harness/src/runner.ts | 30 +++++++++++++------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/browser/tests/e2e/workers/harness/src/cases-hardening.ts b/browser/tests/e2e/workers/harness/src/cases-hardening.ts index 42ea6ebf0..2c1e3d6aa 100644 --- a/browser/tests/e2e/workers/harness/src/cases-hardening.ts +++ b/browser/tests/e2e/workers/harness/src/cases-hardening.ts @@ -140,9 +140,9 @@ export const HARDENING_CASES: TestCase[] = [ expect(hopped.html.includes('<p>hop=1</p>'), `redirect target did not receive the hop cookie: ${hopped.html}`) expectEqual(hopped.cookies, { hop: '1' }, 'envelope accumulates hop cookies') - // 127.0.0.1 -> localhost is the same server but a different hostname: - // the Cookie header must not replay, while the envelope accumulator - // (deliberately global across hops) still reports it. + // 127.0.0.1 -> [::1] is the same handler on a different loopback + // hostname: the Cookie header must not replay, while the envelope + // accumulator (deliberately global across hops) still reports it. const scoped = await call('browser::fetch', { url: `${origin}/hop-x`, include_html: true, retries: 1 }) expect(scoped.html.includes('<p>none</p>'), `cross-hostname hop leaked the cookie: ${scoped.html}`) expectEqual(scoped.cookies, { hopx: '1' }, 'accumulator still reports every hop cookie') diff --git a/browser/tests/e2e/workers/harness/src/runner.ts b/browser/tests/e2e/workers/harness/src/runner.ts index 54eeb7244..f3a70e899 100644 --- a/browser/tests/e2e/workers/harness/src/runner.ts +++ b/browser/tests/e2e/workers/harness/src/runner.ts @@ -72,7 +72,7 @@ export class Runner { { registerTrigger: async () => {}, unregisterTrigger: async () => {} }, ) - const server = await this.startOrigin() + const servers = await this.startOrigin() const cases = this.opts.filter ? ALL_CASES.filter((c) => c.name.includes(this.opts.filter!)) : ALL_CASES // Stream each case result to stdout as it completes, colored green/red @@ -93,7 +93,7 @@ export class Runner { results.push(r) } } finally { - server.close() + for (const server of servers) server.close() } const pass = results.filter((r) => r.status === 'PASS').length @@ -104,8 +104,9 @@ export class Runner { return { pass, total: results.length, results } } - private async startOrigin(): Promise<Server> { - const server = createServer((req, res) => { + private async startOrigin(): Promise<Server[]> { + let port6 = 0 + const handler = (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => { const path = new URL(req.url ?? '/', 'http://127.0.0.1').pathname const address = server.address() const port = address && typeof address !== 'string' ? address.port : 0 @@ -137,9 +138,11 @@ export class Runner { case '/hop-a': return redirect(`http://127.0.0.1:${port}/hop-b`, { 'Set-Cookie': 'hop=1; Path=/' }) case '/hop-x': - // Same server, different hostname: exercises the hostname-only - // scoping of redirect cookie replay (127.0.0.1 vs localhost). - return redirect(`http://localhost:${port}/hop-b`, { 'Set-Cookie': 'hopx=1; Path=/' }) + // Same handler, different loopback hostname: exercises the + // hostname-only scoping of redirect cookie replay. An IP literal + // (not `localhost`) so no resolver is involved — CI resolves + // localhost to ::1 while dev machines resolve it to 127.0.0.1. + return redirect(`http://[::1]:${port6}/hop-b`, { 'Set-Cookie': 'hopx=1; Path=/' }) case '/hop-b': return html(`<html><body><p>${req.headers.cookie ?? 'none'}</p></body></html>`) case '/multi-cookie': @@ -196,12 +199,21 @@ export class Runner { res.setHeader('Content-Length', body.length) res.setHeader('Connection', 'close') res.end(body) - }) + } + const server = createServer(handler) server.listen(0, '127.0.0.1') await once(server, 'listening') const address = server.address() if (!address || typeof address === 'string') throw new Error('local origin did not bind TCP') this.origin = `http://127.0.0.1:${address.port}` - return server + // IPv6-loopback twin (own ephemeral port): the second loopback hostname + // the hop-x redirect targets. Same handler, so /hop-b answers on both. + const server6 = createServer(handler) + server6.listen(0, '::1') + await once(server6, 'listening') + const address6 = server6.address() + if (!address6 || typeof address6 === 'string') throw new Error('local v6 origin did not bind TCP') + port6 = address6.port + return [server, server6] } } From bb99232d3e1ae00b932c6b854a67b05a9faa5ec0 Mon Sep 17 00:00:00 2001 From: Anderson Leal <andersonofl@gmail.com> Date: Wed, 19 Aug 2026 13:19:04 -0300 Subject: [PATCH 4/8] fix(browser): heal the three red CI gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent failures, none introduced by the e2e work: - Compat certification (x86_64): the rebase onto main picked up scrapling/ worker changes (configuration.py, main.py, guidance.py, metadata) made after oracle/manifest.json froze the source fingerprint. Re-freeze the manifest's source section and prove parse parity is unchanged: 19 schema goldens + 135 behavior fixtures byte-identical, 30k parser differential cases and the HTTP differential suite green against the locked oracle. verify_oracle.py now reads the recorded version from scrapling/pyproject instead of a hardcode the CI version bot silently stales. - Compat certification (aarch64): fetch_chromium_artifacts.sh used ln -sfn onto a path the restored rust-cache can materialize as a real directory, which ln refuses to overwrite. rm the destinations first (rerun-safe). - browser: rust lint + test: the generic per-worker gate runs --all-features, but browser's scrapling-compat feature links a pinned curl-impersonate artifact and its tests hard-require the frozen Chrome 148 — both fetched only by the dedicated compat-certification job, which already runs the identical fmt/clippy/test --all-features gate on both Tier-1 targets. The generic gate now covers browser's default feature set (clippy --all-targets and the full 206-test suite, verified locally); the workflow-contract strings in test_rust_ci_workflows.py still hold. --- .github/workflows/ci.yml | 18 ++++++++++ browser/oracle/manifest.json | 40 +++++++++++++-------- browser/scripts/fetch_chromium_artifacts.sh | 4 +++ browser/scripts/verify_oracle.py | 13 ++++++- 4 files changed, 59 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa260e35d..91f0352c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -220,12 +220,30 @@ jobs: - name: Check formatting run: cargo fmt --all -- --check + # browser is the one worker whose --all-features build needs external + # pinned artifacts: `scrapling-compat` links a certified + # curl-impersonate static library and its tests hard-require the frozen + # Chrome 148. That exact gate (fmt + clippy + test, --all-features, on + # both Tier-1 targets, artifacts fetched and verified) runs in + # browser-scrapling-e2e.yml's compat-certification job on every + # browser/** PR, so the generic gate covers browser's default feature + # set instead of duplicating the artifact plumbing here. - name: Run clippy + if: matrix.worker != 'browser' run: cargo clippy --locked --all-targets --all-features -- -D warnings + - name: Run clippy (default features) + if: matrix.worker == 'browser' + run: cargo clippy --locked --all-targets -- -D warnings + - name: Run tests + if: matrix.worker != 'browser' run: cargo test --locked --all-features + - name: Run tests (default features) + if: matrix.worker == 'browser' + run: cargo test --locked + # ────────────────────────────────────────────────────────────── # Shared Rust crates (crates/*): lint + test. Workers link these by # path; discover also fans a changed crate's dependents into the rust diff --git a/browser/oracle/manifest.json b/browser/oracle/manifest.json index a4592e96f..51e7e478d 100644 --- a/browser/oracle/manifest.json +++ b/browser/oracle/manifest.json @@ -1,8 +1,8 @@ { "format": 1, "source": { - "version": "0.2.6", - "sha256": "aed73077a9bfe523f842e8e5133f0d9a941dbb6b9862dae6c0f7bb1c27e12c1f", + "version": "0.2.7", + "sha256": "89703be8308706553be710257a3db5d926fb0b2e583d4e9f5aa55ca84d94f727", "files": [ { "path": "scrapling/.gitignore", @@ -11,8 +11,8 @@ }, { "path": "scrapling/README.md", - "size": 4749, - "sha256": "f98ddca029ca09fd6046239b2b8c2abeb515f2f607ebab632d1e2a6829c07d7b" + "size": 5023, + "sha256": "5f17075bd41eaef9afa860cd76f9b15d9ef192aebeecde6d274cb6d8d914d8a4" }, { "path": "scrapling/config.yaml", @@ -21,18 +21,18 @@ }, { "path": "scrapling/iii-permissions.yaml", - "size": 692, - "sha256": "d8d2639f0b89b7fbd40affe5fd56721d4699d3aeb80883ab13cdc2b90d208902" + "size": 872, + "sha256": "7f266b2bab15c7bcc470481ee7581416604fa85168088ebf9cfa254783d6a543" }, { "path": "scrapling/iii.worker.yaml", - "size": 1787, - "sha256": "7266df502cb6e4f4a6cea26fd5fe91641a0795d73e590ba997f5b17f948ec2ae" + "size": 1829, + "sha256": "7d3cff8a25ab3d6cf9aa1d27a2c1bdf371888ac9c33c279a34d7004c5cac74a6" }, { "path": "scrapling/pyproject.toml", "size": 1154, - "sha256": "cd9e0c0eda2cd3f53811706aff2a66c63d439f81a3089b6524a2aed06c2ed41b" + "sha256": "b360776014d319fffc820a2d63c1d10a81cafb2f6ddeb807180cb740ba91d0fd" }, { "path": "scrapling/skills/SKILL.md", @@ -44,6 +44,11 @@ "size": 0, "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, + { + "path": "scrapling/src/configuration.py", + "size": 6952, + "sha256": "289d50ce45b5062553bd8680ad8d170fd14049a85f45164ac85d68f6ed3605c7" + }, { "path": "scrapling/src/core.py", "size": 20102, @@ -56,8 +61,8 @@ }, { "path": "scrapling/src/guidance.py", - "size": 6492, - "sha256": "e010648370f6ca1858a5e9292d943cdbcd1578c86fac6bb2bc6175675d3d4764" + "size": 8506, + "sha256": "ac8be6a8334dccdf2505ed806832a2641a75e0ea2be30519dd47e8f6dddf2b74" }, { "path": "scrapling/src/handlers.py", @@ -66,8 +71,8 @@ }, { "path": "scrapling/src/main.py", - "size": 2700, - "sha256": "e787274476a69635815340342f3015c93d4c4698c060e6d8df9de0295bde126d" + "size": 4154, + "sha256": "d05ac774de9e17704ed9c22075ea7541d14c68786f99d8e4c386d0381990d16d" }, { "path": "scrapling/src/schemas.py", @@ -94,6 +99,11 @@ "size": 2259, "sha256": "c8e8cbf0fd7a8754ddbd10daeca0f91f9c1916856bffa95a9c3d5ee71cf0c027" }, + { + "path": "scrapling/tests/test_configuration.py", + "size": 5845, + "sha256": "097e2b362bfcd42c9f19ba5eeefe6d61347b2f16eadb7a5564c52fa4606640ac" + }, { "path": "scrapling/tests/test_crawl.py", "size": 8406, @@ -111,8 +121,8 @@ }, { "path": "scrapling/tests/test_guidance.py", - "size": 2798, - "sha256": "cbdda7cd12b234744b1a157bc4ce5f21913411d033399109c805c174b1e33ad4" + "size": 3951, + "sha256": "91b1d23193b62778ed387b6ca677bef733500b18c45c415861820caae83184d5" }, { "path": "scrapling/tests/test_main.py", diff --git a/browser/scripts/fetch_chromium_artifacts.sh b/browser/scripts/fetch_chromium_artifacts.sh index 882555a04..fdb3e138b 100755 --- a/browser/scripts/fetch_chromium_artifacts.sh +++ b/browser/scripts/fetch_chromium_artifacts.sh @@ -61,6 +61,10 @@ if [[ $mode == fetch ]]; then chrome_dir=$(find "$target_dir" -mindepth 1 -maxdepth 1 -type d -name 'chrome-linux*' ! -name 'chrome-headless*' | head -1) headless_dir=$(find "$target_dir" -mindepth 1 -maxdepth 1 -type d -name 'chrome-headless-shell-linux*' | head -1) mkdir -p "$target_dir/pw/chromium-1223" "$target_dir/pw/chromium_headless_shell-1223" + # rm before ln: a restored CI cache can materialize the link destination as + # a real directory, and `ln -sfn` refuses to overwrite one. + rm -rf "$target_dir/pw/chromium-1223/$(basename "$chrome_dir")" \ + "$target_dir/pw/chromium_headless_shell-1223/$(basename "$headless_dir")" ln -sfn "$chrome_dir" "$target_dir/pw/chromium-1223/$(basename "$chrome_dir")" ln -sfn "$headless_dir" "$target_dir/pw/chromium_headless_shell-1223/$(basename "$headless_dir")" fi diff --git a/browser/scripts/verify_oracle.py b/browser/scripts/verify_oracle.py index d83fc528b..68388cf9c 100644 --- a/browser/scripts/verify_oracle.py +++ b/browser/scripts/verify_oracle.py @@ -76,13 +76,24 @@ def records_digest(records: list[dict[str, object]]) -> str: return digest.hexdigest() +def source_version() -> str: + # The CI bot bumps this after every scrapling merge; reading it keeps the + # recorded version from silently lying (pyproject.toml is itself one of + # the fingerprinted files, so a bump already forces a re-freeze). + pyproject = (REPO / "scrapling/pyproject.toml").read_text() + match = re.search(r'(?m)^version = "([^"]+)"$', pyproject) + if not match: + raise SystemExit("cannot read version from scrapling/pyproject.toml") + return match.group(1) + + def source_manifest() -> dict[str, object]: output = subprocess.check_output( ["git", "ls-files", "-z", "scrapling"], cwd=REPO ) paths = [Path(item.decode()) for item in output.split(b"\0") if item] files = [file_record(REPO / path, str(path)) for path in paths] - return {"version": "0.2.6", "sha256": records_digest(files), "files": files} + return {"version": source_version(), "sha256": records_digest(files), "files": files} def canonical_name(value: str) -> str: From 515a5d53263acae9e4575691fded25c01355085c Mon Sep 17 00:00:00 2001 From: Anderson Leal <andersonofl@gmail.com> Date: Wed, 19 Aug 2026 13:28:35 -0300 Subject: [PATCH 5/8] fix(browser): recognize the merged arm64 chromium layout and name oracle diffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arm64 archives are Playwright chromium builds that both unpack into chrome-linux/ (the x64 Chrome-for-Testing zips have distinct roots), so the headless-shell find matched nothing and the pw symlink step ran on an empty variable — that empty expansion, not a cache restore, was the real cause of both aarch64 fetch failures. The script now uses the merged directory (which is exactly Playwright's arm64 layout: chrome-linux/chrome + chrome-linux/headless_shell) and fails loudly if neither layout is found. Verified against the real pinned arm64 archives locally. verify_oracle.py now prints which leaf paths disagree before exiting; two CI round-trips have already been spent on an opaque 'differs' with no detail. --- browser/scripts/fetch_chromium_artifacts.sh | 17 +++++++++-- browser/scripts/verify_oracle.py | 31 +++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/browser/scripts/fetch_chromium_artifacts.sh b/browser/scripts/fetch_chromium_artifacts.sh index fdb3e138b..d15d2fdc4 100755 --- a/browser/scripts/fetch_chromium_artifacts.sh +++ b/browser/scripts/fetch_chromium_artifacts.sh @@ -60,9 +60,22 @@ PY if [[ $mode == fetch ]]; then chrome_dir=$(find "$target_dir" -mindepth 1 -maxdepth 1 -type d -name 'chrome-linux*' ! -name 'chrome-headless*' | head -1) headless_dir=$(find "$target_dir" -mindepth 1 -maxdepth 1 -type d -name 'chrome-headless-shell-linux*' | head -1) + # The x64 archives are Chrome-for-Testing builds with distinct roots + # (chrome-linux64/, chrome-headless-shell-linux64/), but the arm64 ones are + # Playwright chromium builds that BOTH unpack into chrome-linux/ — after the + # merged extraction the headless shell lives inside the chrome directory, + # and that merged directory is what Playwright's arm64 layout expects at + # both pw/ locations (chrome-linux/chrome, chrome-linux/headless_shell). + if [[ -z $headless_dir && -n $chrome_dir && -f "$chrome_dir/headless_shell" ]]; then + headless_dir=$chrome_dir + fi + [[ -n $chrome_dir && -n $headless_dir ]] || { + echo "extracted chromium layout not recognized under $target_dir" >&2 + exit 1 + } mkdir -p "$target_dir/pw/chromium-1223" "$target_dir/pw/chromium_headless_shell-1223" - # rm before ln: a restored CI cache can materialize the link destination as - # a real directory, and `ln -sfn` refuses to overwrite one. + # rm before ln: `ln -sfn` refuses to overwrite a destination that already + # exists as a real directory (e.g. restored from a CI cache). rm -rf "$target_dir/pw/chromium-1223/$(basename "$chrome_dir")" \ "$target_dir/pw/chromium_headless_shell-1223/$(basename "$headless_dir")" ln -sfn "$chrome_dir" "$target_dir/pw/chromium-1223/$(basename "$chrome_dir")" diff --git a/browser/scripts/verify_oracle.py b/browser/scripts/verify_oracle.py index 68388cf9c..8be462ad3 100644 --- a/browser/scripts/verify_oracle.py +++ b/browser/scripts/verify_oracle.py @@ -241,6 +241,7 @@ def verify(archive_dir: Path | None) -> None: }, } if current != expected: + report_diff(expected, current) raise SystemExit("oracle environment differs from oracle/manifest.json") if archive_dir: verify_archives(expected, archive_dir) @@ -280,10 +281,40 @@ def verify_parser_runtime() -> None: "assets": expected["assets"], } if current != frozen: + report_diff(frozen, current) raise SystemExit("parser oracle runtime differs from oracle/manifest.json") print("parser oracle runtime verified") +def report_diff(frozen: object, current: object, path: str = "", budget: list[int] | None = None) -> None: + """Print the leaf paths where the snapshots disagree (first 20).""" + if budget is None: + budget = [20] + if budget[0] <= 0: + return + if isinstance(frozen, dict) and isinstance(current, dict): + for key in sorted(set(frozen) | set(current)): + if key not in frozen: + budget[0] -= 1 + print(f"diff {path}.{key}: only in current", file=sys.stderr) + elif key not in current: + budget[0] -= 1 + print(f"diff {path}.{key}: only frozen", file=sys.stderr) + else: + report_diff(frozen[key], current[key], f"{path}.{key}", budget) + return + if isinstance(frozen, list) and isinstance(current, list): + if len(frozen) != len(current): + budget[0] -= 1 + print(f"diff {path}: {len(frozen)} frozen items vs {len(current)} current", file=sys.stderr) + for index, (a, b) in enumerate(zip(frozen, current)): + report_diff(a, b, f"{path}[{index}]", budget) + return + if frozen != current: + budget[0] -= 1 + print(f"diff {path}: frozen={frozen!r} current={current!r}", file=sys.stderr) + + def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--write", action="store_true") From beb2f7fe18fc466d3080b01eb40be27e009bfe1b Mon Sep 17 00:00:00 2001 From: Anderson Leal <andersonofl@gmail.com> Date: Wed, 19 Aug 2026 13:39:52 -0300 Subject: [PATCH 6/8] fix(browser): scope the oracle freeze to executed source and certify both Tier-1 Chromium builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more compat-certification root causes, both surfaced by the new diff printer: - PR CI checks out the MERGE commit, so fingerprinting all of scrapling/** meant any main-side metadata churn (here: #834 touching iii.worker.yaml dependency ranges) broke every open PR's freeze without affecting parse behavior. The source fingerprint now covers scrapling/src only — exactly what gen_goldens.py imports (its dependencies come from oracle/requirements.lock, not worker metadata) — and the version label, which the CI bot bumps every merge, is recorded as provenance but excluded from the parser-runtime comparison. scrapling/src is byte-identical between this branch and main, so the freeze now holds on the merge ref. - The aarch64 frozen Chromium is the Playwright build 1223, which reports 148.0.7778.0, not the x64 Chrome-for-Testing 148.0.7778.96 — the certified version pin is now the set of both frozen Tier-1 builds, shared between certify_chromium and the certified session test. --- browser/oracle/manifest.json | 102 +-------------------------- browser/scripts/verify_oracle.py | 19 ++++- browser/src/scrapling/raw_browser.rs | 12 +++- browser/src/scrapling/sessions.rs | 6 +- 4 files changed, 29 insertions(+), 110 deletions(-) diff --git a/browser/oracle/manifest.json b/browser/oracle/manifest.json index 51e7e478d..7e62aca1d 100644 --- a/browser/oracle/manifest.json +++ b/browser/oracle/manifest.json @@ -2,43 +2,8 @@ "format": 1, "source": { "version": "0.2.7", - "sha256": "89703be8308706553be710257a3db5d926fb0b2e583d4e9f5aa55ca84d94f727", + "sha256": "0982638efeedff41286af00cc858a6804985c58f78b9d1188b522276c54c9c47", "files": [ - { - "path": "scrapling/.gitignore", - "size": 116, - "sha256": "2d60003e5d625fbfed94267283237d378c1c36f9dda0013bac8434304493f497" - }, - { - "path": "scrapling/README.md", - "size": 5023, - "sha256": "5f17075bd41eaef9afa860cd76f9b15d9ef192aebeecde6d274cb6d8d914d8a4" - }, - { - "path": "scrapling/config.yaml", - "size": 1387, - "sha256": "b5cb08c7bef71d2b9dc8949b2565adfe7e7d76ad934ebbe955fdf238c4e7c648" - }, - { - "path": "scrapling/iii-permissions.yaml", - "size": 872, - "sha256": "7f266b2bab15c7bcc470481ee7581416604fa85168088ebf9cfa254783d6a543" - }, - { - "path": "scrapling/iii.worker.yaml", - "size": 1829, - "sha256": "7d3cff8a25ab3d6cf9aa1d27a2c1bdf371888ac9c33c279a34d7004c5cac74a6" - }, - { - "path": "scrapling/pyproject.toml", - "size": 1154, - "sha256": "b360776014d319fffc820a2d63c1d10a81cafb2f6ddeb807180cb740ba91d0fd" - }, - { - "path": "scrapling/skills/SKILL.md", - "size": 3461, - "sha256": "f4bdd7922233e2557d246cfb7ccfe454ce52f80d4b3c7507c39e0a113749f19c" - }, { "path": "scrapling/src/__init__.py", "size": 0, @@ -88,71 +53,6 @@ "path": "scrapling/src/storage.py", "size": 1454, "sha256": "aaf5d94ca150f46f35154107f9c05602faf25ffb37417c0f210a742e66469615" - }, - { - "path": "scrapling/tests/__init__.py", - "size": 0, - "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - }, - { - "path": "scrapling/tests/test_adaptive.py", - "size": 2259, - "sha256": "c8e8cbf0fd7a8754ddbd10daeca0f91f9c1916856bffa95a9c3d5ee71cf0c027" - }, - { - "path": "scrapling/tests/test_configuration.py", - "size": 5845, - "sha256": "097e2b362bfcd42c9f19ba5eeefe6d61347b2f16eadb7a5564c52fa4606640ac" - }, - { - "path": "scrapling/tests/test_crawl.py", - "size": 8406, - "sha256": "b67ff5cacc07ed194a7a2cc386acf3f55f5e8d39218f8dcddc2650a84d5fcb26" - }, - { - "path": "scrapling/tests/test_extract.py", - "size": 5605, - "sha256": "e79811b374f3735242f0247777a177f8b4ad60040551e14936c9221941a6028a" - }, - { - "path": "scrapling/tests/test_fetch.py", - "size": 5697, - "sha256": "1d77ce5a317301372c893d5fcd8f78c34daf5bf83715b9e3c1e9bd1bc0de04a3" - }, - { - "path": "scrapling/tests/test_guidance.py", - "size": 3951, - "sha256": "91b1d23193b62778ed387b6ca677bef733500b18c45c415861820caae83184d5" - }, - { - "path": "scrapling/tests/test_main.py", - "size": 336, - "sha256": "d95bea62d9bbabab72510dbb1348750c8d4a7740e67fd094e1079748e7b9657e" - }, - { - "path": "scrapling/tests/test_register.py", - "size": 1530, - "sha256": "dd01197b19bffcd739c5e2916ff3dbb0c61bae00e1f626ebe4bb662116d616f2" - }, - { - "path": "scrapling/tests/test_screenshot.py", - "size": 3559, - "sha256": "3120eded718159d5d767a029fd1e89b2953585e917ba00ef4eabd19784652edf" - }, - { - "path": "scrapling/tests/test_sessions.py", - "size": 5907, - "sha256": "8bddd0427b1544e2495c2e8bb0388933cdcc13aa13a7d52fe147fc6f81c07c88" - }, - { - "path": "scrapling/vendor/iii_helpers-0.21.4-py3-none-any.whl", - "size": 27438, - "sha256": "7ba233214ef45df3bc5e5e2644c2f9a30a919848ec5bf558da4ecb6d74dfbbe4" - }, - { - "path": "scrapling/vendor/iii_sdk-0.21.4-py3-none-any.whl", - "size": 36912, - "sha256": "e1a3334b6e92c65e45e82b453cca8e71baba32817a9914505c6034b0a34d8b3b" } ] }, diff --git a/browser/scripts/verify_oracle.py b/browser/scripts/verify_oracle.py index 8be462ad3..85ee7b1b5 100644 --- a/browser/scripts/verify_oracle.py +++ b/browser/scripts/verify_oracle.py @@ -88,8 +88,15 @@ def source_version() -> str: def source_manifest() -> dict[str, object]: + # Fingerprint only the source the oracle executes (gen_goldens.py imports + # scrapling/src directly; its dependencies come from oracle/ + # requirements.lock, not the worker's own metadata). Worker metadata — + # iii.worker.yaml, README, permissions, pyproject — is deliberately + # excluded: PR CI runs on the merge commit, so any main-side churn in + # those files would break every open PR's freeze without touching parse + # behavior. The version label is provenance, not a compared input. output = subprocess.check_output( - ["git", "ls-files", "-z", "scrapling"], cwd=REPO + ["git", "ls-files", "-z", "scrapling/src"], cwd=REPO ) paths = [Path(item.decode()) for item in output.split(b"\0") if item] files = [file_record(REPO / path, str(path)) for path in paths] @@ -252,8 +259,14 @@ def verify_parser_runtime() -> None: """Verify inputs that can affect parse differentials, excluding host/browser data.""" expected = json.loads(MANIFEST.read_text()) packages, assets, parser_runtime_sha256 = package_manifest() + + def compared_source(source: dict[str, object]) -> dict[str, object]: + # The version label tracks scrapling/pyproject.toml, which the CI bot + # bumps after every merge; it is provenance, not a parse input. + return {key: source[key] for key in ("sha256", "files")} + current = { - "source": source_manifest(), + "source": compared_source(source_manifest()), "python": { "version": sys.version.split()[0], "implementation": sys.implementation.name, @@ -267,7 +280,7 @@ def verify_parser_runtime() -> None: "assets": assets, } frozen = { - "source": expected["source"], + "source": compared_source(expected["source"]), "python": { "version": expected["python"]["version"], "implementation": expected["python"]["implementation"], diff --git a/browser/src/scrapling/raw_browser.rs b/browser/src/scrapling/raw_browser.rs index b61cae18e..4bd9506ec 100644 --- a/browser/src/scrapling/raw_browser.rs +++ b/browser/src/scrapling/raw_browser.rs @@ -19,7 +19,12 @@ use crate::scrapling::egress_gate::EgressGate; use crate::scrapling::page::PageData; use crate::ssrf::SsrfPolicy; -const CERTIFIED_CHROME_VERSION: &str = "148.0.7778.96"; +/// The frozen Tier-1 Chromium builds compat mode certifies against: the +/// x86_64 Chrome-for-Testing 148 build and the aarch64 Playwright chromium +/// build 1223 (same 148 milestone; Playwright snapshots report patch .0). +/// Both are pinned by sha256 in oracle/manifest.json and fetched by +/// scripts/fetch_chromium_artifacts.sh. +pub(crate) const CERTIFIED_CHROME_VERSIONS: &[&str] = &["148.0.7778.96", "148.0.7778.0"]; const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"; const GOOGLE_REFERER: &str = "https://www.google.com/"; const MAX_TILE_WIDTH: u32 = 1024; @@ -2292,12 +2297,13 @@ fn certify_chromium(path: &Path) -> Result<(), String> { .ok_or_else(|| format!("could not read Chromium version from {}", path.display()))?; if version .split_whitespace() - .any(|value| value == CERTIFIED_CHROME_VERSION) + .any(|value| CERTIFIED_CHROME_VERSIONS.contains(&value)) { Ok(()) } else { Err(format!( - "compat mode requires Chrome {CERTIFIED_CHROME_VERSION}; {} reports {version}", + "compat mode requires Chrome {}; {} reports {version}", + CERTIFIED_CHROME_VERSIONS.join(" or "), path.display() )) } diff --git a/browser/src/scrapling/sessions.rs b/browser/src/scrapling/sessions.rs index a5404d4cc..8775d6434 100644 --- a/browser/src/scrapling/sessions.rs +++ b/browser/src/scrapling/sessions.rs @@ -729,9 +729,9 @@ mod tests { .or_else(|| crate::functions::doctor::detect_chromium(&WorkerConfig::default())); let Some(executable) = executable.filter(|executable| { crate::functions::doctor::chromium_version(executable).is_some_and(|version| { - version - .split_whitespace() - .any(|part| part == "148.0.7778.96") + version.split_whitespace().any(|part| { + crate::scrapling::raw_browser::CERTIFIED_CHROME_VERSIONS.contains(&part) + }) }) }) else { #[cfg(feature = "scrapling-compat")] From 00d8cfe75ac82a96e370367c41ea1867f0dd5274 Mon Sep 17 00:00:00 2001 From: Anderson Leal <andersonofl@gmail.com> Date: Wed, 19 Aug 2026 14:44:49 -0300 Subject: [PATCH 7/8] chore(browser): trim the xmloxide fork to the slice the worker parses with The worker consumes html (parser + entities), tree, serial, xpath, and the parser core (which needs validation::dtd); everything else in the vendored fork was upstream dead weight: the WHATWG html5 parser, CSS engine, SAX/reader streaming APIs, RelaxNG/XSD/Schematron validators, XInclude, catalogs, serde/async integration, the FFI layer, the xmllint CLI, benches, and their examples. Removing them cuts 34.7k lines from the vendored tree (the PR's largest single block) with zero behavior surface: the fork's remaining 702 unit tests + 40 doctests pass, strict clippy is clean, and the browser suite incl. the 135 Python-parity fixtures is green against the trimmed fork. The fork README and lib.rs now document exactly what was removed and that upstream is the restore source. --- browser/Cargo.lock | 2 - browser/vendor/xmloxide/Cargo.toml | 8 - browser/vendor/xmloxide/README.md | 9 + .../xmloxide/benches/comparison_bench.rs | 224 - .../xmloxide/benches/ecosystem_bench.rs | 252 - .../vendor/xmloxide/benches/parser_bench.rs | 626 --- browser/vendor/xmloxide/examples/ffi_usage.c | 89 - browser/vendor/xmloxide/examples/reader.rs | 73 - .../vendor/xmloxide/examples/sax_streaming.rs | 82 - .../vendor/xmloxide/examples/validation.rs | 112 - browser/vendor/xmloxide/examples/xinclude.rs | 47 - .../vendor/xmloxide/include/libxml2_compat.h | 316 -- browser/vendor/xmloxide/include/xmloxide.h | 1007 ---- browser/vendor/xmloxide/src/async_xml.rs | 146 - browser/vendor/xmloxide/src/bin/xmllint.rs | 923 ---- browser/vendor/xmloxide/src/catalog/mod.rs | 1196 ----- browser/vendor/xmloxide/src/css/eval.rs | 987 ---- browser/vendor/xmloxide/src/css/mod.rs | 340 -- browser/vendor/xmloxide/src/css/parser.rs | 580 -- browser/vendor/xmloxide/src/css/types.rs | 138 - browser/vendor/xmloxide/src/ffi/c14n.rs | 99 - browser/vendor/xmloxide/src/ffi/catalog.rs | 144 - browser/vendor/xmloxide/src/ffi/css.rs | 133 - browser/vendor/xmloxide/src/ffi/document.rs | 328 -- browser/vendor/xmloxide/src/ffi/html5.rs | 99 - browser/vendor/xmloxide/src/ffi/mod.rs | 135 - browser/vendor/xmloxide/src/ffi/push.rs | 90 - browser/vendor/xmloxide/src/ffi/reader.rs | 317 -- browser/vendor/xmloxide/src/ffi/sax.rs | 193 - browser/vendor/xmloxide/src/ffi/serial.rs | 136 - browser/vendor/xmloxide/src/ffi/strings.rs | 33 - browser/vendor/xmloxide/src/ffi/tree.rs | 734 --- browser/vendor/xmloxide/src/ffi/validation.rs | 467 -- browser/vendor/xmloxide/src/ffi/xinclude.rs | 37 - browser/vendor/xmloxide/src/ffi/xpath.rs | 305 -- browser/vendor/xmloxide/src/html5/entities.rs | 2318 -------- browser/vendor/xmloxide/src/html5/mod.rs | 84 - browser/vendor/xmloxide/src/html5/sax.rs | 399 -- .../vendor/xmloxide/src/html5/tokenizer.rs | 3374 ------------ .../vendor/xmloxide/src/html5/tree_builder.rs | 4721 ----------------- browser/vendor/xmloxide/src/lib.rs | 29 +- browser/vendor/xmloxide/src/reader/mod.rs | 1676 ------ browser/vendor/xmloxide/src/sax/mod.rs | 915 ---- browser/vendor/xmloxide/src/serde_xml/de.rs | 613 --- .../vendor/xmloxide/src/serde_xml/error.rs | 41 - browser/vendor/xmloxide/src/serde_xml/mod.rs | 46 - browser/vendor/xmloxide/src/serde_xml/ser.rs | 887 ---- browser/vendor/xmloxide/src/serial/html.rs | 239 - browser/vendor/xmloxide/src/validation/mod.rs | 12 +- .../vendor/xmloxide/src/validation/relaxng.rs | 2479 --------- .../xmloxide/src/validation/schematron.rs | 1951 ------- browser/vendor/xmloxide/src/validation/xsd.rs | 3731 ------------- browser/vendor/xmloxide/src/xinclude/mod.rs | 853 --- 53 files changed, 20 insertions(+), 34755 deletions(-) delete mode 100644 browser/vendor/xmloxide/benches/comparison_bench.rs delete mode 100644 browser/vendor/xmloxide/benches/ecosystem_bench.rs delete mode 100644 browser/vendor/xmloxide/benches/parser_bench.rs delete mode 100644 browser/vendor/xmloxide/examples/ffi_usage.c delete mode 100644 browser/vendor/xmloxide/examples/reader.rs delete mode 100644 browser/vendor/xmloxide/examples/sax_streaming.rs delete mode 100644 browser/vendor/xmloxide/examples/validation.rs delete mode 100644 browser/vendor/xmloxide/examples/xinclude.rs delete mode 100644 browser/vendor/xmloxide/include/libxml2_compat.h delete mode 100644 browser/vendor/xmloxide/include/xmloxide.h delete mode 100644 browser/vendor/xmloxide/src/async_xml.rs delete mode 100644 browser/vendor/xmloxide/src/bin/xmllint.rs delete mode 100644 browser/vendor/xmloxide/src/catalog/mod.rs delete mode 100644 browser/vendor/xmloxide/src/css/eval.rs delete mode 100644 browser/vendor/xmloxide/src/css/mod.rs delete mode 100644 browser/vendor/xmloxide/src/css/parser.rs delete mode 100644 browser/vendor/xmloxide/src/css/types.rs delete mode 100644 browser/vendor/xmloxide/src/ffi/c14n.rs delete mode 100644 browser/vendor/xmloxide/src/ffi/catalog.rs delete mode 100644 browser/vendor/xmloxide/src/ffi/css.rs delete mode 100644 browser/vendor/xmloxide/src/ffi/document.rs delete mode 100644 browser/vendor/xmloxide/src/ffi/html5.rs delete mode 100644 browser/vendor/xmloxide/src/ffi/mod.rs delete mode 100644 browser/vendor/xmloxide/src/ffi/push.rs delete mode 100644 browser/vendor/xmloxide/src/ffi/reader.rs delete mode 100644 browser/vendor/xmloxide/src/ffi/sax.rs delete mode 100644 browser/vendor/xmloxide/src/ffi/serial.rs delete mode 100644 browser/vendor/xmloxide/src/ffi/strings.rs delete mode 100644 browser/vendor/xmloxide/src/ffi/tree.rs delete mode 100644 browser/vendor/xmloxide/src/ffi/validation.rs delete mode 100644 browser/vendor/xmloxide/src/ffi/xinclude.rs delete mode 100644 browser/vendor/xmloxide/src/ffi/xpath.rs delete mode 100644 browser/vendor/xmloxide/src/html5/entities.rs delete mode 100644 browser/vendor/xmloxide/src/html5/mod.rs delete mode 100644 browser/vendor/xmloxide/src/html5/sax.rs delete mode 100644 browser/vendor/xmloxide/src/html5/tokenizer.rs delete mode 100644 browser/vendor/xmloxide/src/html5/tree_builder.rs delete mode 100644 browser/vendor/xmloxide/src/reader/mod.rs delete mode 100644 browser/vendor/xmloxide/src/sax/mod.rs delete mode 100644 browser/vendor/xmloxide/src/serde_xml/de.rs delete mode 100644 browser/vendor/xmloxide/src/serde_xml/error.rs delete mode 100644 browser/vendor/xmloxide/src/serde_xml/mod.rs delete mode 100644 browser/vendor/xmloxide/src/serde_xml/ser.rs delete mode 100644 browser/vendor/xmloxide/src/validation/relaxng.rs delete mode 100644 browser/vendor/xmloxide/src/validation/schematron.rs delete mode 100644 browser/vendor/xmloxide/src/validation/xsd.rs delete mode 100644 browser/vendor/xmloxide/src/xinclude/mod.rs diff --git a/browser/Cargo.lock b/browser/Cargo.lock index 43a3250a5..8b659c7fc 100644 --- a/browser/Cargo.lock +++ b/browser/Cargo.lock @@ -3042,8 +3042,6 @@ name = "xmloxide" version = "0.5.0" dependencies = [ "encoding_rs", - "serde", - "tokio", ] [[package]] diff --git a/browser/vendor/xmloxide/Cargo.toml b/browser/vendor/xmloxide/Cargo.toml index e7c0f6de1..c7ec219a0 100644 --- a/browser/vendor/xmloxide/Cargo.toml +++ b/browser/vendor/xmloxide/Cargo.toml @@ -8,16 +8,8 @@ autobenches = false license = "MIT" description = "Repository-owned Scrapling compatibility fork of xmloxide" -[features] -default = [] -ffi = [] -serde = ["dep:serde"] -async = ["dep:tokio"] - [dependencies] encoding_rs = "0.8" -serde = { version = "1", optional = true } -tokio = { version = "1", features = ["io-util"], optional = true } [lib] path = "src/lib.rs" diff --git a/browser/vendor/xmloxide/README.md b/browser/vendor/xmloxide/README.md index 065e27abb..545ed331b 100644 --- a/browser/vendor/xmloxide/README.md +++ b/browser/vendor/xmloxide/README.md @@ -1,5 +1,14 @@ # xmloxide +> **Scrapling-compatibility fork, trimmed.** This tree is the repository-owned +> fork the browser worker parses with (lxml-parity patches on top of upstream) +> and is cut down to the modules the worker consumes: `html`, `tree`, `serial`, +> `xpath`, `parser`, `validation::dtd`, `encoding`, `error`. Upstream's WHATWG +> html5 parser, CSS engine, SAX/reader APIs, RelaxNG/XSD/Schematron validators, +> XInclude, catalogs, serde/async integration, FFI layer, and xmllint CLI are +> removed — restore them from upstream if ever needed. The feature list below +> is upstream's, kept for provenance. + [![CI](https://github.com/jonwiggins/xmloxide/actions/workflows/ci.yml/badge.svg)](https://github.com/jonwiggins/xmloxide/actions/workflows/ci.yml) [![crates.io](https://img.shields.io/crates/v/xmloxide.svg)](https://crates.io/crates/xmloxide) [![docs.rs](https://docs.rs/xmloxide/badge.svg)](https://docs.rs/xmloxide) diff --git a/browser/vendor/xmloxide/benches/comparison_bench.rs b/browser/vendor/xmloxide/benches/comparison_bench.rs deleted file mode 100644 index 7f254d05b..000000000 --- a/browser/vendor/xmloxide/benches/comparison_bench.rs +++ /dev/null @@ -1,224 +0,0 @@ -//! Head-to-head benchmark comparing xmloxide against libxml2. -//! -//! Run with: `cargo bench --features bench-libxml2 --bench comparison_bench` -#![allow(clippy::expect_used, clippy::unwrap_used)] - -use std::fmt::Write; - -use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; - -use xmloxide::parser::ParseOptions; -use xmloxide::sax::{parse_sax, SaxHandler}; -use xmloxide::serial::serialize; -use xmloxide::xpath::evaluate; -use xmloxide::Document; - -#[cfg(feature = "bench-libxml2")] -use libxml::parser::Parser as LibxmlParser; -#[cfg(feature = "bench-libxml2")] -use libxml::xpath::Context as LibxmlXPathContext; - -// --------------------------------------------------------------------------- -// Fixture loading -// --------------------------------------------------------------------------- - -const ATOM_FEED: &str = include_str!("fixtures/atom_feed.xml"); -const SVG_DRAWING: &str = include_str!("fixtures/svg_drawing.xml"); -const MAVEN_POM: &str = include_str!("fixtures/maven_pom.xml"); -const XHTML_PAGE: &str = include_str!("fixtures/xhtml_page.xml"); - -/// Generates a large XML document at runtime (~100KB). -fn make_large_xml() -> String { - let mut xml = String::from("<?xml version=\"1.0\"?>\n<database>\n"); - for i in 0..2000 { - let _ = writeln!( - xml, - " <record id=\"{i}\" status=\"active\" priority=\"{}\">\ - <name>Record {i}</name>\ - <value>{}</value>\ - <description>This is the description for record number {i} in our database.</description>\ - </record>", - i % 5, - i * 42 - ); - } - xml.push_str("</database>\n"); - xml -} - -// --------------------------------------------------------------------------- -// Parse throughput benchmarks -// --------------------------------------------------------------------------- - -fn bench_parse_throughput(c: &mut Criterion) { - let large_xml = make_large_xml(); - - let fixtures: Vec<(&str, &str)> = vec![ - ("atom_feed", ATOM_FEED), - ("svg_drawing", SVG_DRAWING), - ("maven_pom", MAVEN_POM), - ("xhtml_page", XHTML_PAGE), - ("large_generated", &large_xml), - ]; - - let mut group = c.benchmark_group("parse_throughput"); - - for (name, xml) in &fixtures { - group.throughput(criterion::Throughput::Bytes(xml.len() as u64)); - - group.bench_with_input(BenchmarkId::new("xmloxide", name), xml, |b, xml| { - b.iter(|| Document::parse_str(black_box(xml))); - }); - - #[cfg(feature = "bench-libxml2")] - group.bench_with_input(BenchmarkId::new("libxml2", name), xml, |b, xml| { - let parser = LibxmlParser::default(); - b.iter(|| parser.parse_string(black_box(xml))); - }); - } - - group.finish(); -} - -// --------------------------------------------------------------------------- -// Serialize throughput benchmarks -// --------------------------------------------------------------------------- - -fn bench_serialize_throughput(c: &mut Criterion) { - let large_xml = make_large_xml(); - - let fixtures: Vec<(&str, &str)> = vec![ - ("atom_feed", ATOM_FEED), - ("maven_pom", MAVEN_POM), - ("large_generated", &large_xml), - ]; - - let mut group = c.benchmark_group("serialize_throughput"); - - for (name, xml) in &fixtures { - // xmloxide serialize - let doc = Document::parse_str(xml).expect("xmloxide parse failed"); - group.bench_with_input(BenchmarkId::new("xmloxide", name), &doc, |b, doc| { - b.iter(|| serialize(black_box(doc))); - }); - - // libxml2 serialize - #[cfg(feature = "bench-libxml2")] - { - let parser = LibxmlParser::default(); - let libxml_doc = parser.parse_string(xml).expect("libxml2 parse failed"); - group.bench_function(BenchmarkId::new("libxml2", name), |b| { - b.iter(|| { - let _ = black_box(libxml_doc.to_string()); - }); - }); - } - } - - group.finish(); -} - -// --------------------------------------------------------------------------- -// XPath benchmarks -// --------------------------------------------------------------------------- - -fn bench_xpath(c: &mut Criterion) { - let expressions: Vec<(&str, &str, &str)> = vec![ - ("simple_path", ATOM_FEED, "//entry/title"), - ("attribute_pred", MAVEN_POM, "//dependency[scope='test']"), - ("count_func", ATOM_FEED, "count(//entry)"), - ("string_func", ATOM_FEED, "string(//feed/title)"), - ]; - - let mut group = c.benchmark_group("xpath"); - - for (name, xml, expr) in &expressions { - // xmloxide xpath - let doc = Document::parse_str(xml).expect("xmloxide parse failed"); - let root = doc.root(); - group.bench_function(BenchmarkId::new("xmloxide", name), |b| { - b.iter(|| evaluate(black_box(&doc), root, black_box(expr))); - }); - - // libxml2 xpath - #[cfg(feature = "bench-libxml2")] - { - let parser = LibxmlParser::default(); - let libxml_doc = parser.parse_string(xml).expect("libxml2 parse failed"); - let ctx = LibxmlXPathContext::new(&libxml_doc).expect("xpath context failed"); - group.bench_function(BenchmarkId::new("libxml2", name), |b| { - b.iter(|| ctx.evaluate(black_box(expr))); - }); - } - } - - group.finish(); -} - -// --------------------------------------------------------------------------- -// SAX streaming benchmark (xmloxide only — libxml crate has no SAX API) -// --------------------------------------------------------------------------- - -struct CountingHandler { - elements: u64, - characters: u64, -} - -impl SaxHandler for CountingHandler { - fn start_element( - &mut self, - _local_name: &str, - _prefix: Option<&str>, - _namespace: Option<&str>, - _attributes: &[(String, String, Option<String>, Option<String>)], - ) { - self.elements += 1; - } - - fn characters(&mut self, _content: &str) { - self.characters += 1; - } -} - -fn bench_sax_streaming(c: &mut Criterion) { - let large_xml = make_large_xml(); - - let fixtures: Vec<(&str, &str)> = vec![ - ("atom_feed", ATOM_FEED), - ("maven_pom", MAVEN_POM), - ("large_generated", &large_xml), - ]; - - let mut group = c.benchmark_group("sax_streaming"); - let options = ParseOptions::default(); - - for (name, xml) in &fixtures { - group.throughput(criterion::Throughput::Bytes(xml.len() as u64)); - group.bench_with_input(BenchmarkId::new("xmloxide", name), xml, |b, xml| { - b.iter(|| { - let mut handler = CountingHandler { - elements: 0, - characters: 0, - }; - parse_sax(black_box(xml), &options, &mut handler).expect("SAX parse failed"); - black_box(handler.elements); - }); - }); - } - - group.finish(); -} - -// --------------------------------------------------------------------------- -// Criterion groups and main -// --------------------------------------------------------------------------- - -criterion_group!( - benches, - bench_parse_throughput, - bench_serialize_throughput, - bench_xpath, - bench_sax_streaming, -); - -criterion_main!(benches); diff --git a/browser/vendor/xmloxide/benches/ecosystem_bench.rs b/browser/vendor/xmloxide/benches/ecosystem_bench.rs deleted file mode 100644 index f598d877a..000000000 --- a/browser/vendor/xmloxide/benches/ecosystem_bench.rs +++ /dev/null @@ -1,252 +0,0 @@ -//! Head-to-head benchmarks comparing xmloxide against roxmltree and quick-xml. -//! -//! Run with: `cargo bench --features bench-rust-xml --bench ecosystem_bench` -#![allow(clippy::expect_used, clippy::unwrap_used)] - -use std::fmt::Write; - -use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; - -use xmloxide::Document; - -// --------------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------------- - -const ATOM_FEED: &str = include_str!("fixtures/atom_feed.xml"); -const SVG_DRAWING: &str = include_str!("fixtures/svg_drawing.xml"); -const MAVEN_POM: &str = include_str!("fixtures/maven_pom.xml"); - -/// Generates a large XML document at runtime (~100KB). -fn make_large_xml() -> String { - let mut xml = String::from("<?xml version=\"1.0\"?>\n<database>\n"); - for i in 0..2000 { - let _ = writeln!( - xml, - " <record id=\"{i}\" status=\"active\" priority=\"{}\">\ - <name>Record {i}</name>\ - <value>{}</value>\ - <description>Description for record {i}.</description>\ - </record>", - i % 5, - i * 42 - ); - } - xml.push_str("</database>\n"); - xml -} - -// --------------------------------------------------------------------------- -// Parse benchmarks -// --------------------------------------------------------------------------- - -fn bench_parse_throughput(c: &mut Criterion) { - let large = make_large_xml(); - - let fixtures: Vec<(&str, &str)> = vec![ - ("atom_feed", ATOM_FEED), - ("svg_drawing", SVG_DRAWING), - ("maven_pom", MAVEN_POM), - ("large_2000", &large), - ]; - - let mut group = c.benchmark_group("parse"); - - for (name, xml) in &fixtures { - let bytes = xml.len() as u64; - group.throughput(Throughput::Bytes(bytes)); - - group.bench_with_input(BenchmarkId::new("xmloxide", name), xml, |b, xml| { - b.iter(|| { - let doc = Document::parse_str(black_box(xml)).unwrap(); - black_box(doc.root_element()); - }); - }); - - group.bench_with_input(BenchmarkId::new("roxmltree", name), xml, |b, xml| { - b.iter(|| { - let doc = roxmltree::Document::parse(black_box(xml)).unwrap(); - black_box(doc.root_element()); - }); - }); - - group.bench_with_input(BenchmarkId::new("quick-xml/reader", name), xml, |b, xml| { - b.iter(|| { - use quick_xml::events::Event; - use quick_xml::Reader; - let mut reader = Reader::from_str(black_box(xml)); - let mut count = 0u64; - let mut buf = Vec::new(); - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Eof) => break, - Ok(_) => count += 1, - Err(e) => panic!("quick-xml error: {e}"), - } - buf.clear(); - } - black_box(count); - }); - }); - } - - group.finish(); -} - -// --------------------------------------------------------------------------- -// Tree navigation benchmarks -// --------------------------------------------------------------------------- - -fn bench_tree_walk(c: &mut Criterion) { - let large = make_large_xml(); - - let mut group = c.benchmark_group("tree_walk"); - - // xmloxide: walk all nodes and count elements - group.bench_function("xmloxide", |b| { - let doc = Document::parse_str(&large).unwrap(); - let root = doc.root_element().unwrap(); - b.iter(|| { - let mut count = 0u64; - for node in doc.descendants(black_box(root)) { - if doc.is_element(node) { - count += 1; - } - } - black_box(count) - }); - }); - - // roxmltree: walk all nodes and count elements - group.bench_function("roxmltree", |b| { - let doc = roxmltree::Document::parse(&large).unwrap(); - let root = doc.root_element(); - b.iter(|| { - let mut count = 0u64; - for node in black_box(root).descendants() { - if node.is_element() { - count += 1; - } - } - black_box(count) - }); - }); - - group.finish(); -} - -// --------------------------------------------------------------------------- -// Attribute access benchmarks -// --------------------------------------------------------------------------- - -fn bench_attr_access(c: &mut Criterion) { - let large = make_large_xml(); - - let mut group = c.benchmark_group("attr_access"); - - // xmloxide: look up 'id' attribute on every element - group.bench_function("xmloxide", |b| { - let doc = Document::parse_str(&large).unwrap(); - let root = doc.root_element().unwrap(); - b.iter(|| { - let mut count = 0u64; - for node in doc.descendants(black_box(root)) { - if doc.attribute(node, "id").is_some() { - count += 1; - } - } - black_box(count) - }); - }); - - // roxmltree: look up 'id' attribute on every element - group.bench_function("roxmltree", |b| { - let doc = roxmltree::Document::parse(&large).unwrap(); - let root = doc.root_element(); - b.iter(|| { - let mut count = 0u64; - for node in black_box(root).descendants() { - if node.attribute("id").is_some() { - count += 1; - } - } - black_box(count) - }); - }); - - group.finish(); -} - -// --------------------------------------------------------------------------- -// Serialization benchmarks -// --------------------------------------------------------------------------- - -fn bench_serialize(c: &mut Criterion) { - let large = make_large_xml(); - - let mut group = c.benchmark_group("serialize"); - group.throughput(Throughput::Bytes(large.len() as u64)); - - // xmloxide: serialize - group.bench_function("xmloxide", |b| { - let doc = Document::parse_str(&large).unwrap(); - b.iter(|| { - let out = xmloxide::serial::serialize(black_box(&doc)); - black_box(out.len()); - }); - }); - - // roxmltree doesn't have serialization, so we only compare xmloxide here - // quick-xml writer is a different API (not DOM-to-string) - - group.finish(); -} - -// --------------------------------------------------------------------------- -// CSS selector benchmarks (xmloxide only — others don't have CSS) -// --------------------------------------------------------------------------- - -fn bench_css_selector(c: &mut Criterion) { - let large = make_large_xml(); - - let mut group = c.benchmark_group("css_selector"); - - group.bench_function("xmloxide/tag", |b| { - let doc = Document::parse_str(&large).unwrap(); - let root = doc.root_element().unwrap(); - b.iter(|| { - let results = xmloxide::css::select(black_box(&doc), root, "record").unwrap(); - black_box(results.len()); - }); - }); - - group.bench_function("xmloxide/attr", |b| { - let doc = Document::parse_str(&large).unwrap(); - let root = doc.root_element().unwrap(); - b.iter(|| { - let results = xmloxide::css::select(black_box(&doc), root, "[priority=\"0\"]").unwrap(); - black_box(results.len()); - }); - }); - - group.bench_function("xmloxide/complex", |b| { - let doc = Document::parse_str(&large).unwrap(); - let root = doc.root_element().unwrap(); - b.iter(|| { - let results = xmloxide::css::select(black_box(&doc), root, "record > name").unwrap(); - black_box(results.len()); - }); - }); - - group.finish(); -} - -criterion_group!( - benches, - bench_parse_throughput, - bench_tree_walk, - bench_attr_access, - bench_serialize, - bench_css_selector, -); -criterion_main!(benches); diff --git a/browser/vendor/xmloxide/benches/parser_bench.rs b/browser/vendor/xmloxide/benches/parser_bench.rs deleted file mode 100644 index 29445f34b..000000000 --- a/browser/vendor/xmloxide/benches/parser_bench.rs +++ /dev/null @@ -1,626 +0,0 @@ -#![allow(clippy::expect_used)] - -use criterion::{black_box, criterion_group, criterion_main, Criterion}; -use std::fmt::Write; -use xmloxide::css; -use xmloxide::html::parse_html; -use xmloxide::html5::{parse_html5, parse_html5_with_options, Html5ParseOptions}; -use xmloxide::parser::{ParseOptions, PushParser}; -use xmloxide::reader::XmlReader; -use xmloxide::sax::{parse_sax, SaxHandler}; -use xmloxide::serial::serialize; -use xmloxide::validation::{dtd, relaxng, schematron, xsd}; -use xmloxide::xpath::evaluate; -use xmloxide::Document; - -// --------------------------------------------------------------------------- -// Document generators -// --------------------------------------------------------------------------- - -/// Generates a small XML document with approximately 10 elements. -fn make_small_xml() -> String { - let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root>\n"); - for i in 0..10 { - let _ = writeln!(xml, " <item id=\"{i}\">Value {i}</item>"); - } - xml.push_str("</root>\n"); - xml -} - -/// Generates a medium XML document with approximately 100 elements. -fn make_medium_xml() -> String { - let mut xml = String::from("<?xml version=\"1.0\"?>\n<catalog>\n"); - for i in 0..100 { - let _ = writeln!( - xml, - " <book id=\"bk{i}\"><title>Title {i}</title>\ - <author>Author {i}</author>\ - <price>{}.99</price></book>", - 10 + i - ); - } - xml.push_str("</catalog>\n"); - xml -} - -/// Generates a large XML document with approximately 1000 elements. -fn make_large_xml() -> String { - let mut xml = String::from("<?xml version=\"1.0\"?>\n<database>\n"); - for i in 0..1000 { - let _ = writeln!( - xml, - " <record id=\"{i}\"><name>Record {i}</name>\ - <value>{}</value><status>active</status></record>", - i * 42 - ); - } - xml.push_str("</database>\n"); - xml -} - -/// Generates a deeply nested XML document with the given nesting depth. -fn make_nested_xml(depth: usize) -> String { - let mut xml = String::from("<?xml version=\"1.0\"?>\n"); - for i in 0..depth { - let _ = write!(xml, "<level{i}>"); - } - xml.push_str("leaf"); - for i in (0..depth).rev() { - let _ = write!(xml, "</level{i}>"); - } - xml.push('\n'); - xml -} - -/// Generates an XML document where each element has `num_attrs` attributes. -fn make_attr_heavy_xml(num_attrs: usize) -> String { - let mut xml = String::from("<?xml version=\"1.0\"?>\n<root>\n"); - for i in 0..10 { - let _ = write!(xml, " <element"); - for j in 0..num_attrs { - let _ = write!(xml, " attr{j}=\"value_{i}_{j}\""); - } - xml.push_str("/>\n"); - } - xml.push_str("</root>\n"); - xml -} - -/// Generates an XML document with many namespace declarations and prefixed -/// elements. -fn make_namespace_heavy_xml() -> String { - let mut xml = String::from("<?xml version=\"1.0\"?>\n<root"); - for i in 0..20 { - let _ = write!(xml, " xmlns:ns{i}=\"http://example.com/ns{i}\""); - } - xml.push_str(">\n"); - for i in 0..100 { - let ns = i % 20; - let _ = writeln!( - xml, - " <ns{ns}:item ns{ns}:id=\"{i}\">Content {i}</ns{ns}:item>" - ); - } - xml.push_str("</root>\n"); - xml -} - -/// Generates an HTML document for benchmarking the HTML parser. -fn make_html_doc() -> String { - let mut html = String::from( - "<!DOCTYPE html>\n<html>\n<head>\n\ - <title>Benchmark Page</title>\n\ - <meta charset=\"utf-8\">\n\ - <link rel=\"stylesheet\" href=\"style.css\">\n\ - </head>\n<body>\n<h1>Benchmark</h1>\n", - ); - for i in 0..50 { - let _ = writeln!( - html, - "<div class=\"section\" id=\"s{i}\">\ - <p>Paragraph {i} with <b>bold</b> and <i>italic</i> text.</p>\ - <ul><li>Item A</li><li>Item B</li><li>Item C</li></ul>\ - <img src=\"img{i}.png\" alt=\"Image {i}\">\ - <a href=\"#s{i}\">Link {i}</a>\ - </div>" - ); - } - html.push_str("</body>\n</html>\n"); - html -} - -/// Generates a medium XML document suitable for `XPath` benchmarks, with a -/// structure that exercises path navigation and predicates. -fn make_xpath_xml() -> String { - let mut xml = String::from( - "<?xml version=\"1.0\"?>\n\ - <library>\n", - ); - for i in 0..50 { - let genre = match i % 4 { - 0 => "fiction", - 1 => "science", - 2 => "history", - _ => "poetry", - }; - let _ = writeln!( - xml, - " <book genre=\"{genre}\" id=\"{i}\">\ - <title>Book {i}</title>\ - <author>Author {}</author>\ - <year>{}</year>\ - <price>{}.99</price>\ - </book>", - i % 10, - 2000 + i, - 10 + i - ); - } - xml.push_str("</library>\n"); - xml -} - -// --------------------------------------------------------------------------- -// XML Parsing benchmarks -// --------------------------------------------------------------------------- - -fn bench_parse_small(c: &mut Criterion) { - let xml = make_small_xml(); - c.bench_function("parse_small", |b| { - b.iter(|| Document::parse_str(black_box(&xml))); - }); -} - -fn bench_parse_medium(c: &mut Criterion) { - let xml = make_medium_xml(); - c.bench_function("parse_medium", |b| { - b.iter(|| Document::parse_str(black_box(&xml))); - }); -} - -fn bench_parse_large(c: &mut Criterion) { - let xml = make_large_xml(); - c.bench_function("parse_large", |b| { - b.iter(|| Document::parse_str(black_box(&xml))); - }); -} - -fn bench_parse_deeply_nested(c: &mut Criterion) { - let xml = make_nested_xml(50); - c.bench_function("parse_deeply_nested", |b| { - b.iter(|| Document::parse_str(black_box(&xml))); - }); -} - -fn bench_parse_many_attributes(c: &mut Criterion) { - let xml = make_attr_heavy_xml(50); - c.bench_function("parse_many_attributes", |b| { - b.iter(|| Document::parse_str(black_box(&xml))); - }); -} - -fn bench_parse_namespace_heavy(c: &mut Criterion) { - let xml = make_namespace_heavy_xml(); - c.bench_function("parse_namespace_heavy", |b| { - b.iter(|| Document::parse_str(black_box(&xml))); - }); -} - -// --------------------------------------------------------------------------- -// Serialization benchmarks -// --------------------------------------------------------------------------- - -fn bench_serialize_small(c: &mut Criterion) { - let xml = make_small_xml(); - let doc = Document::parse_str(&xml).expect("failed to parse small XML"); - c.bench_function("serialize_small", |b| { - b.iter(|| serialize(black_box(&doc))); - }); -} - -fn bench_serialize_large(c: &mut Criterion) { - let xml = make_large_xml(); - let doc = Document::parse_str(&xml).expect("failed to parse large XML"); - c.bench_function("serialize_large", |b| { - b.iter(|| serialize(black_box(&doc))); - }); -} - -// --------------------------------------------------------------------------- -// HTML parsing benchmark -// --------------------------------------------------------------------------- - -fn bench_parse_html(c: &mut Criterion) { - let html = make_html_doc(); - c.bench_function("parse_html", |b| { - b.iter(|| parse_html(black_box(&html))); - }); -} - -// --------------------------------------------------------------------------- -// SAX parsing benchmark -// --------------------------------------------------------------------------- - -/// A minimal SAX handler that counts elements, used for benchmarking the SAX -/// parsing path without allocation overhead from recording events. -struct CountingHandler { - elements: u64, - characters: u64, -} - -impl SaxHandler for CountingHandler { - fn start_element( - &mut self, - _local_name: &str, - _prefix: Option<&str>, - _namespace: Option<&str>, - _attributes: &[(String, String, Option<String>, Option<String>)], - ) { - self.elements += 1; - } - - fn characters(&mut self, _content: &str) { - self.characters += 1; - } -} - -fn bench_sax_parse(c: &mut Criterion) { - let xml = make_medium_xml(); - let options = ParseOptions::default(); - c.bench_function("sax_parse", |b| { - b.iter(|| { - let mut handler = CountingHandler { - elements: 0, - characters: 0, - }; - parse_sax(black_box(&xml), &options, &mut handler).expect("SAX parse failed"); - black_box(handler.elements); - }); - }); -} - -// --------------------------------------------------------------------------- -// XmlReader benchmark -// --------------------------------------------------------------------------- - -fn bench_reader_parse(c: &mut Criterion) { - let xml = make_medium_xml(); - c.bench_function("reader_parse", |b| { - b.iter(|| { - let mut reader = XmlReader::new(black_box(&xml)); - let mut count: u64 = 0; - while reader.read().expect("reader failed") { - count += 1; - } - black_box(count); - }); - }); -} - -// --------------------------------------------------------------------------- -// XPath benchmarks -// --------------------------------------------------------------------------- - -fn bench_xpath_simple(c: &mut Criterion) { - let xml = make_xpath_xml(); - let doc = Document::parse_str(&xml).expect("failed to parse XPath XML"); - let root = doc.root_element().expect("no root element"); - c.bench_function("xpath_simple", |b| { - b.iter(|| evaluate(black_box(&doc), root, "//book/title")); - }); -} - -fn bench_xpath_complex(c: &mut Criterion) { - let xml = make_xpath_xml(); - let doc = Document::parse_str(&xml).expect("failed to parse XPath XML"); - let root = doc.root_element().expect("no root element"); - c.bench_function("xpath_complex", |b| { - b.iter(|| { - evaluate( - black_box(&doc), - root, - "//book[@genre='fiction' and number(price) > 20]/title", - ) - }); - }); -} - -// --------------------------------------------------------------------------- -// Roundtrip benchmark: parse -> serialize -> parse -// --------------------------------------------------------------------------- - -fn bench_roundtrip(c: &mut Criterion) { - let xml = make_medium_xml(); - c.bench_function("roundtrip", |b| { - b.iter(|| { - let doc = Document::parse_str(black_box(&xml)).expect("parse failed"); - let serialized = serialize(&doc); - let doc2 = Document::parse_str(&serialized).expect("re-parse failed"); - black_box(doc2); - }); - }); -} - -// --------------------------------------------------------------------------- -// Push parser benchmark -// --------------------------------------------------------------------------- - -fn bench_push_parser(c: &mut Criterion) { - let xml = make_medium_xml(); - let bytes = xml.as_bytes(); - // Split into ~64-byte chunks to simulate incremental feeding. - let chunk_size = 64; - let chunks: Vec<&[u8]> = bytes.chunks(chunk_size).collect(); - c.bench_function("push_parser", |b| { - b.iter(|| { - let mut parser = PushParser::new(); - for chunk in &chunks { - parser.push(black_box(chunk)); - } - parser.finish().expect("push parse failed") - }); - }); -} - -// --------------------------------------------------------------------------- -// Criterion groups and main -// --------------------------------------------------------------------------- - -criterion_group!( - parsing, - bench_parse_small, - bench_parse_medium, - bench_parse_large, - bench_parse_deeply_nested, - bench_parse_many_attributes, - bench_parse_namespace_heavy, -); - -criterion_group!(serialization, bench_serialize_small, bench_serialize_large,); - -// --------------------------------------------------------------------------- -// HTML5 parsing benchmarks -// --------------------------------------------------------------------------- - -fn bench_parse_html5(c: &mut Criterion) { - let html = make_html_doc(); - c.bench_function("parse_html5", |b| { - b.iter(|| parse_html5(black_box(&html))); - }); -} - -fn bench_parse_html5_fragment(c: &mut Criterion) { - let html = make_html_doc(); - let opts = Html5ParseOptions { - scripting: false, - fragment_context: Some("body".to_string()), - }; - c.bench_function("parse_html5_fragment", |b| { - b.iter(|| parse_html5_with_options(black_box(&html), &opts)); - }); -} - -// --------------------------------------------------------------------------- -// Additional XPath benchmarks -// --------------------------------------------------------------------------- - -fn bench_xpath_count(c: &mut Criterion) { - let xml = make_xpath_xml(); - let doc = Document::parse_str(&xml).expect("failed to parse XPath XML"); - let root = doc.root_element().expect("no root element"); - c.bench_function("xpath_count", |b| { - b.iter(|| evaluate(black_box(&doc), root, "count(//book)")); - }); -} - -fn bench_xpath_string_function(c: &mut Criterion) { - let xml = make_xpath_xml(); - let doc = Document::parse_str(&xml).expect("failed to parse XPath XML"); - let root = doc.root_element().expect("no root element"); - c.bench_function("xpath_string_function", |b| { - b.iter(|| evaluate(black_box(&doc), root, "string(//book[1]/title)")); - }); -} - -fn bench_xpath_position_predicate(c: &mut Criterion) { - let xml = make_xpath_xml(); - let doc = Document::parse_str(&xml).expect("failed to parse XPath XML"); - let root = doc.root_element().expect("no root element"); - c.bench_function("xpath_position_predicate", |b| { - b.iter(|| { - evaluate( - black_box(&doc), - root, - "//book[position() > 10 and position() < 20]", - ) - }); - }); -} - -fn bench_xpath_ancestor(c: &mut Criterion) { - let xml = make_xpath_xml(); - let doc = Document::parse_str(&xml).expect("failed to parse XPath XML"); - let root = doc.root_element().expect("no root element"); - // Get a deep node to evaluate ancestor axis from - let result = evaluate(&doc, root, "//book[1]/title").expect("xpath failed"); - let title_node = result.as_node_set().expect("expected nodeset")[0].anchor(); - c.bench_function("xpath_ancestor", |b| { - b.iter(|| evaluate(black_box(&doc), title_node, "ancestor::*")); - }); -} - -fn bench_xpath_union(c: &mut Criterion) { - let xml = make_xpath_xml(); - let doc = Document::parse_str(&xml).expect("failed to parse XPath XML"); - let root = doc.root_element().expect("no root element"); - c.bench_function("xpath_union", |b| { - b.iter(|| evaluate(black_box(&doc), root, "//title | //author | //year")); - }); -} - -// --------------------------------------------------------------------------- -// Validation benchmarks -// --------------------------------------------------------------------------- - -/// DTD for validating the medium XML (catalog of books). -fn make_book_dtd() -> String { - String::from( - "<!ELEMENT catalog (book*)>\n\ - <!ELEMENT book (title, author, price)>\n\ - <!ATTLIST book id ID #REQUIRED>\n\ - <!ELEMENT title (#PCDATA)>\n\ - <!ELEMENT author (#PCDATA)>\n\ - <!ELEMENT price (#PCDATA)>\n", - ) -} - -fn bench_validate_dtd(c: &mut Criterion) { - let xml = make_medium_xml(); - let dtd_str = make_book_dtd(); - let dtd_schema = dtd::parse_dtd(&dtd_str).expect("DTD parse failed"); - c.bench_function("validate_dtd", |b| { - b.iter(|| { - let mut doc = Document::parse_str(black_box(&xml)).expect("parse failed"); - dtd::validate(black_box(&mut doc), &dtd_schema) - }); - }); -} - -fn bench_validate_relaxng(c: &mut Criterion) { - let schema_xml = r#"<?xml version="1.0"?> -<element name="catalog" xmlns="http://relaxng.org/ns/structure/1.0"> - <zeroOrMore> - <element name="book"> - <attribute name="id"/> - <element name="title"><text/></element> - <element name="author"><text/></element> - <element name="price"><text/></element> - </element> - </zeroOrMore> -</element>"#; - let schema = relaxng::parse_relaxng(schema_xml).expect("RelaxNG parse failed"); - let xml = make_medium_xml(); - let doc = Document::parse_str(&xml).expect("parse failed"); - c.bench_function("validate_relaxng", |b| { - b.iter(|| relaxng::validate(black_box(&doc), &schema)); - }); -} - -fn bench_validate_xsd(c: &mut Criterion) { - let schema_xml = r#"<?xml version="1.0"?> -<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="catalog"> - <xs:complexType> - <xs:sequence> - <xs:element name="book" maxOccurs="unbounded" minOccurs="0"> - <xs:complexType> - <xs:sequence> - <xs:element name="title" type="xs:string"/> - <xs:element name="author" type="xs:string"/> - <xs:element name="price" type="xs:string"/> - </xs:sequence> - <xs:attribute name="id" type="xs:string" use="required"/> - </xs:complexType> - </xs:element> - </xs:sequence> - </xs:complexType> - </xs:element> -</xs:schema>"#; - let schema = xsd::parse_xsd(schema_xml).expect("XSD parse failed"); - let xml = make_medium_xml(); - let doc = Document::parse_str(&xml).expect("parse failed"); - c.bench_function("validate_xsd", |b| { - b.iter(|| xsd::validate_xsd(black_box(&doc), &schema)); - }); -} - -fn bench_validate_schematron(c: &mut Criterion) { - let schema_xml = r#"<schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="book"> - <assert test="title">book must have a title</assert> - <assert test="author">book must have an author</assert> - <assert test="@id">book must have an id attribute</assert> - </rule> - </pattern> -</schema>"#; - let schema = schematron::parse_schematron(schema_xml).expect("Schematron parse failed"); - let xml = make_medium_xml(); - let doc = Document::parse_str(&xml).expect("parse failed"); - c.bench_function("validate_schematron", |b| { - b.iter(|| schematron::validate_schematron(black_box(&doc), &schema)); - }); -} - -// --------------------------------------------------------------------------- -// CSS selector benchmark -// --------------------------------------------------------------------------- - -fn bench_css_select(c: &mut Criterion) { - let html = make_html_doc(); - let doc = parse_html5(&html).expect("html5 parse failed"); - let root = doc.root_element().expect("no root"); - c.bench_function("css_select_class", |b| { - b.iter(|| css::select(black_box(&doc), root, "div.section")); - }); -} - -fn bench_css_select_complex(c: &mut Criterion) { - let html = make_html_doc(); - let doc = parse_html5(&html).expect("html5 parse failed"); - let root = doc.root_element().expect("no root"); - c.bench_function("css_select_complex", |b| { - b.iter(|| css::select(black_box(&doc), root, "div.section > p > b")); - }); -} - -// --------------------------------------------------------------------------- -// Criterion groups and main -// --------------------------------------------------------------------------- - -criterion_group!(html_parsing, bench_parse_html); - -criterion_group!(html5_parsing, bench_parse_html5, bench_parse_html5_fragment); - -criterion_group!(sax, bench_sax_parse); - -criterion_group!(reader, bench_reader_parse); - -criterion_group!( - xpath, - bench_xpath_simple, - bench_xpath_complex, - bench_xpath_count, - bench_xpath_string_function, - bench_xpath_position_predicate, - bench_xpath_ancestor, - bench_xpath_union, -); - -criterion_group!(roundtrip, bench_roundtrip); - -criterion_group!(push, bench_push_parser); - -criterion_group!( - validation, - bench_validate_dtd, - bench_validate_relaxng, - bench_validate_xsd, - bench_validate_schematron, -); - -criterion_group!(css_selectors, bench_css_select, bench_css_select_complex,); - -criterion_main!( - parsing, - serialization, - html_parsing, - html5_parsing, - sax, - reader, - xpath, - roundtrip, - push, - validation, - css_selectors, -); diff --git a/browser/vendor/xmloxide/examples/ffi_usage.c b/browser/vendor/xmloxide/examples/ffi_usage.c deleted file mode 100644 index c7c50b6dc..000000000 --- a/browser/vendor/xmloxide/examples/ffi_usage.c +++ /dev/null @@ -1,89 +0,0 @@ -/* - * ffi_usage.c — Example of using xmloxide from C - * - * Build: - * # First build the shared library: - * cargo rustc --lib --release --features ffi -- --crate-type cdylib - * - * # Then compile this example (adjust library path as needed): - * cc -o ffi_usage examples/ffi_usage.c -Iinclude \ - * -Ltarget/release -lxmloxide -lpthread -ldl -lm - * - * # On macOS, also pass: -framework Security - * # Run with: LD_LIBRARY_PATH=target/release ./ffi_usage - * # or: DYLD_LIBRARY_PATH=target/release ./ffi_usage - */ - -#include <stdio.h> -#include <stdlib.h> -#include "xmloxide.h" - -int main(void) { - /* --- Parse an XML document --- */ - const char *xml = "<library>" - " <book id=\"1\"><title>The Rust Programming Language</title></book>" - " <book id=\"2\"><title>Programming Rust</title></book>" - "</library>"; - - xmloxide_document *doc = xmloxide_parse_str(xml); - if (!doc) { - fprintf(stderr, "Parse error: %s\n", xmloxide_last_error()); - return 1; - } - - /* --- Navigate the tree --- */ - uint32_t root = xmloxide_doc_root_element(doc); - char *root_name = xmloxide_node_name(doc, root); - printf("Root element: %s\n", root_name); - xmloxide_free_string(root_name); - - /* Iterate children */ - uint32_t child = xmloxide_node_first_child(doc, root); - while (child) { - if (xmloxide_node_type(doc, child) == XMLOXIDE_NODE_ELEMENT) { - char *name = xmloxide_node_name(doc, child); - char *id = xmloxide_node_attribute(doc, child, "id"); - char *text = xmloxide_node_text_content(doc, child); - printf(" <%s id=\"%s\">%s</%s>\n", name, id ? id : "", text, name); - xmloxide_free_string(name); - xmloxide_free_string(id); - xmloxide_free_string(text); - } - child = xmloxide_node_next_sibling(doc, child); - } - - /* --- XPath query --- */ - xmloxide_xpath_value *result = xmloxide_xpath_eval(doc, 0, "count(//book)"); - if (result) { - printf("Book count: %.0f\n", xmloxide_xpath_result_number(result)); - xmloxide_xpath_free_result(result); - } - - /* --- Serialize --- */ - char *output = xmloxide_serialize(doc); - printf("Serialized: %s\n", output); - xmloxide_free_string(output); - - /* --- Pretty-print --- */ - char *pretty = xmloxide_serialize_pretty(doc); - printf("Pretty:\n%s\n", pretty); - xmloxide_free_string(pretty); - - /* --- Mutate the tree --- */ - uint32_t new_book = xmloxide_create_element(doc, "book"); - xmloxide_set_attribute(doc, new_book, "id", "3"); - uint32_t title = xmloxide_create_element(doc, "title"); - uint32_t title_text = xmloxide_create_text(doc, "Zero To Production"); - xmloxide_append_child(doc, title, title_text); - xmloxide_append_child(doc, new_book, title); - xmloxide_append_child(doc, root, new_book); - - char *after = xmloxide_serialize(doc); - printf("After mutation: %s\n", after); - xmloxide_free_string(after); - - xmloxide_free_doc(doc); - - printf("Done.\n"); - return 0; -} diff --git a/browser/vendor/xmloxide/examples/reader.rs b/browser/vendor/xmloxide/examples/reader.rs deleted file mode 100644 index 8d8864656..000000000 --- a/browser/vendor/xmloxide/examples/reader.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Pull-based `XmlReader` streaming example. -//! -//! The `XmlReader` provides a cursor-style interface for reading XML -//! documents one node at a time without building a full DOM tree. -//! -//! Run with: `cargo run --example reader` -#![allow(clippy::expect_used)] - -use xmloxide::reader::{XmlNodeType, XmlReader}; - -fn main() { - let xml = r#"<?xml version="1.0"?> -<catalog> - <product id="1" category="electronics"> - <name>Widget</name> - <price currency="USD">29.99</price> - </product> - <product id="2" category="books"> - <name>XML Handbook</name> - <price currency="USD">49.99</price> - </product> -</catalog>"#; - - let mut reader = XmlReader::new(xml); - let mut depth: usize = 0; - - println!("Walking the XML document node by node:\n"); - - while reader.read().expect("read failed") { - let indent = " ".repeat(depth); - match reader.node_type() { - XmlNodeType::Element => { - let name = reader.name().unwrap_or("?"); - let attr_count = reader.attribute_count(); - if attr_count > 0 { - print!("{indent}<{name}"); - // Walk attributes - if reader.move_to_first_attribute() { - loop { - let aname = reader.name().unwrap_or("?"); - let aval = reader.value().unwrap_or("?"); - print!(" {aname}=\"{aval}\""); - if !reader.move_to_next_attribute() { - break; - } - } - reader.move_to_element(); - } - println!(">"); - } else { - println!("{indent}<{name}>"); - } - if !reader.is_empty_element() { - depth += 1; - } - } - XmlNodeType::EndElement => { - depth -= 1; - let indent = " ".repeat(depth); - let name = reader.name().unwrap_or("?"); - println!("{indent}</{name}>"); - } - XmlNodeType::Text => { - let text = reader.value().unwrap_or(""); - let trimmed = text.trim(); - if !trimmed.is_empty() { - println!("{indent}TEXT: \"{trimmed}\""); - } - } - _ => {} - } - } -} diff --git a/browser/vendor/xmloxide/examples/sax_streaming.rs b/browser/vendor/xmloxide/examples/sax_streaming.rs deleted file mode 100644 index 0bed54a64..000000000 --- a/browser/vendor/xmloxide/examples/sax_streaming.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! SAX2 streaming parser example. -//! -//! Run with: `cargo run --example sax_streaming` -#![allow(clippy::expect_used)] - -use xmloxide::parser::ParseOptions; -use xmloxide::sax::{parse_sax, SaxHandler}; - -/// A handler that tracks element depth and prints events. -struct PrintHandler { - depth: usize, -} - -impl SaxHandler for PrintHandler { - fn start_document(&mut self) { - println!("--- Document start ---"); - } - - fn end_document(&mut self) { - println!("--- Document end ---"); - } - - fn start_element( - &mut self, - local_name: &str, - prefix: Option<&str>, - _namespace: Option<&str>, - attributes: &[(String, String, Option<String>, Option<String>)], - ) { - let indent = " ".repeat(self.depth); - let name = match prefix { - Some(p) => format!("{p}:{local_name}"), - None => local_name.to_string(), - }; - if attributes.is_empty() { - println!("{indent}<{name}>"); - } else { - let attrs: Vec<String> = attributes - .iter() - .map(|(local, value, _, _)| format!("{local}=\"{value}\"")) - .collect(); - println!("{indent}<{name} {}>", attrs.join(" ")); - } - self.depth += 1; - } - - fn end_element(&mut self, local_name: &str, prefix: Option<&str>, _namespace: Option<&str>) { - self.depth -= 1; - let indent = " ".repeat(self.depth); - let name = match prefix { - Some(p) => format!("{p}:{local_name}"), - None => local_name.to_string(), - }; - println!("{indent}</{name}>"); - } - - fn characters(&mut self, content: &str) { - let trimmed = content.trim(); - if !trimmed.is_empty() { - let indent = " ".repeat(self.depth); - println!("{indent}TEXT: \"{trimmed}\""); - } - } -} - -fn main() { - let xml = r#"<?xml version="1.0"?> -<catalog> - <product id="1" category="electronics"> - <name>Widget</name> - <price>29.99</price> - </product> - <product id="2" category="books"> - <name>XML Handbook</name> - <price>49.99</price> - </product> -</catalog>"#; - - let mut handler = PrintHandler { depth: 0 }; - let options = ParseOptions::default(); - parse_sax(xml, &options, &mut handler).expect("SAX parsing failed"); -} diff --git a/browser/vendor/xmloxide/examples/validation.rs b/browser/vendor/xmloxide/examples/validation.rs deleted file mode 100644 index 7de9605a5..000000000 --- a/browser/vendor/xmloxide/examples/validation.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! DTD, `RelaxNG`, and XSD validation examples. -//! -//! xmloxide supports validating XML documents against DTD, `RelaxNG`, and -//! XML Schema (XSD) schemas. -//! -//! Run with: `cargo run --example validation` -#![allow(clippy::expect_used)] - -use xmloxide::validation::dtd::{parse_dtd, validate}; -use xmloxide::validation::relaxng::{parse_relaxng, validate as validate_rng}; -use xmloxide::validation::xsd::{parse_xsd, validate_xsd}; -use xmloxide::Document; - -fn main() { - dtd_example(); - relaxng_example(); - xsd_example(); -} - -fn dtd_example() { - println!("=== DTD Validation ===\n"); - - let dtd_str = r" - <!ELEMENT catalog (book+)> - <!ELEMENT book (title, author)> - <!ELEMENT title (#PCDATA)> - <!ELEMENT author (#PCDATA)> - <!ATTLIST book id ID #REQUIRED> - "; - - // Valid document - let valid_xml = r#"<catalog> - <book id="b1"><title>Rust Programming</title><author>Alice</author></book> - <book id="b2"><title>XML Essentials</title><author>Bob</author></book> - </catalog>"#; - - let dtd = parse_dtd(dtd_str).expect("DTD parse failed"); - let mut doc = Document::parse_str(valid_xml).expect("XML parse failed"); - let result = validate(&mut doc, &dtd); - println!("Valid document: is_valid={}", result.is_valid); - - // Invalid document (missing required element) - let invalid_xml = r#"<catalog> - <book id="b1"><title>No Author</title></book> - </catalog>"#; - - let mut doc = Document::parse_str(invalid_xml).expect("XML parse failed"); - let result = validate(&mut doc, &dtd); - println!("Invalid document: is_valid={}", result.is_valid); - for err in &result.errors { - println!(" Error: {err}"); - } - println!(); -} - -fn relaxng_example() { - println!("=== RelaxNG Validation ===\n"); - - let schema_xml = r#"<element name="person" xmlns="http://relaxng.org/ns/structure/1.0"> - <element name="name"><text/></element> - <element name="email"><text/></element> - </element>"#; - - let valid_xml = "<person><name>Alice</name><email>alice@example.com</email></person>"; - let invalid_xml = "<person><name>Bob</name></person>"; - - let schema = parse_relaxng(schema_xml).expect("RelaxNG parse failed"); - - let doc = Document::parse_str(valid_xml).expect("XML parse failed"); - let result = validate_rng(&doc, &schema); - println!("Valid document: is_valid={}", result.is_valid); - - let doc = Document::parse_str(invalid_xml).expect("XML parse failed"); - let result = validate_rng(&doc, &schema); - println!("Invalid document: is_valid={}", result.is_valid); - for err in &result.errors { - println!(" Error: {err}"); - } - println!(); -} - -fn xsd_example() { - println!("=== XSD Validation ===\n"); - - let schema_xml = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="temperature"> - <xs:complexType> - <xs:simpleContent> - <xs:extension base="xs:decimal"> - <xs:attribute name="unit" type="xs:string" use="required"/> - </xs:extension> - </xs:simpleContent> - </xs:complexType> - </xs:element> - </xs:schema>"#; - - let valid_xml = r#"<temperature unit="celsius">36.6</temperature>"#; - let invalid_xml = r#"<temperature unit="celsius">not-a-number</temperature>"#; - - let schema = parse_xsd(schema_xml).expect("XSD parse failed"); - - let doc = Document::parse_str(valid_xml).expect("XML parse failed"); - let result = validate_xsd(&doc, &schema); - println!("Valid document: is_valid={}", result.is_valid); - - let doc = Document::parse_str(invalid_xml).expect("XML parse failed"); - let result = validate_xsd(&doc, &schema); - println!("Invalid document: is_valid={}", result.is_valid); - for err in &result.errors { - println!(" Error: {err}"); - } -} diff --git a/browser/vendor/xmloxide/examples/xinclude.rs b/browser/vendor/xmloxide/examples/xinclude.rs deleted file mode 100644 index e3964c896..000000000 --- a/browser/vendor/xmloxide/examples/xinclude.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! `XInclude` document inclusion example. -//! -//! `XInclude` allows XML documents to include content from other sources -//! via `xi:include` elements. xmloxide processes these inclusions using -//! a resolver callback that you provide. -//! -//! Run with: `cargo run --example xinclude` -#![allow(clippy::expect_used)] - -use xmloxide::serial::serialize; -use xmloxide::xinclude::{process_xincludes, XIncludeOptions}; -use xmloxide::Document; - -fn main() { - // Main document with xi:include elements - let main_xml = r#"<?xml version="1.0"?> -<manual xmlns:xi="http://www.w3.org/2001/XInclude"> - <title>User Guide</title> - <xi:include href="chapter1.xml"/> - <xi:include href="chapter2.xml"/> - <xi:include href="missing.xml"> - <xi:fallback><section><title>Coming Soon</title></section></xi:fallback> - </xi:include> -</manual>"#; - - // Simulated external files - let chapter1 = "<chapter><title>Getting Started</title><p>Welcome to xmloxide.</p></chapter>"; - let chapter2 = - "<chapter><title>Advanced Usage</title><p>XPath, validation, and more.</p></chapter>"; - - let mut doc = Document::parse_str(main_xml).expect("parse failed"); - - // Process XIncludes with a resolver that returns file content - let result = process_xincludes( - &mut doc, - |href| match href { - "chapter1.xml" => Some(chapter1.to_string()), - "chapter2.xml" => Some(chapter2.to_string()), - _ => None, // missing.xml will use the fallback - }, - &XIncludeOptions::default(), - ); - - println!("Inclusions processed: {}", result.inclusions); - println!("Errors: {}", result.errors.len()); - println!("\nResult:\n{}", serialize(&doc)); -} diff --git a/browser/vendor/xmloxide/include/libxml2_compat.h b/browser/vendor/xmloxide/include/libxml2_compat.h deleted file mode 100644 index dc044f0a4..000000000 --- a/browser/vendor/xmloxide/include/libxml2_compat.h +++ /dev/null @@ -1,316 +0,0 @@ -/* - * libxml2_compat.h — libxml2-like API adaptor for xmloxide - * - * This header provides a thin compatibility layer that maps common libxml2 - * function names and types to xmloxide's C FFI. It covers the most frequently - * used libxml2 APIs (parsing, tree navigation, serialization, XPath) to ease - * migration from libxml2 to xmloxide. - * - * Usage: - * #include "libxml2_compat.h" - * // Use familiar libxml2 names — they delegate to xmloxide - * - * Limitations: - * - Node pointers are NOT dereferenceable structs. You cannot write - * node->name or node->children. Use the accessor functions instead. - * - No global state: xmlInitParser() and xmlCleanupParser() are no-ops. - * - No custom error handlers (xmlSetGenericErrorFunc is a no-op). - * - Only covers commonly-used APIs. See xmloxide.h for the full API. - * - * Requires: xmloxide.h (include it first or let this header include it). - */ - -#ifndef LIBXML2_COMPAT_H -#define LIBXML2_COMPAT_H - -#include "xmloxide.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* ======================================================================== - * Type aliases - * ======================================================================== */ - -/** Opaque document type (replaces libxml2's xmlDoc / xmlDocPtr). */ -typedef xmloxide_document xmlDoc; -typedef xmloxide_document *xmlDocPtr; - -/** - * Node handle — NOT a dereferenceable pointer like libxml2's xmlNode. - * - * In xmloxide, nodes are identified by a (document, node_id) pair. - * This struct wraps both so you can pass "node pointers" around. - * Access node properties via xmlNodeGetName(), xmlNodeGetContent(), etc. - */ -typedef struct { - xmloxide_document *doc; - uint32_t id; -} xmlNode; -typedef xmlNode *xmlNodePtr; - -/** XPath result type. */ -typedef xmloxide_xpath_value xmlXPathObject; -typedef xmloxide_xpath_value *xmlXPathObjectPtr; - -/* ======================================================================== - * Global lifecycle — no-ops (xmloxide has no global state) - * ======================================================================== */ - -/** No-op. xmloxide requires no global initialization. */ -static inline void xmlInitParser(void) {} - -/** No-op. xmloxide requires no global cleanup. */ -static inline void xmlCleanupParser(void) {} - -/** No-op. xmloxide has no global memory tracking. */ -static inline void xmlMemoryDump(void) {} - -/* ======================================================================== - * Parsing - * ======================================================================== */ - -/** Parse a null-terminated XML string. Returns NULL on failure. */ -static inline xmlDocPtr xmlParseDoc(const char *input) { - return xmloxide_parse_str(input); -} - -/** Parse a buffer of `size` bytes as XML. Returns NULL on failure. */ -static inline xmlDocPtr xmlReadMemory(const char *buffer, int size, - const char *url, const char *encoding, - int options) { - (void)url; (void)encoding; (void)options; - return xmloxide_parse_bytes((const uint8_t *)buffer, (size_t)size); -} - -/** Parse an XML file. Returns NULL on failure. */ -static inline xmlDocPtr xmlReadFile(const char *filename, const char *encoding, - int options) { - (void)encoding; (void)options; - return xmloxide_parse_file(filename); -} - -/** Parse an HTML string. Returns NULL on failure. */ -static inline xmlDocPtr htmlReadMemory(const char *buffer, int size, - const char *url, const char *encoding, - int options) { - (void)size; (void)url; (void)encoding; (void)options; - return xmloxide_parse_html(buffer); -} - -/** Parse an HTML5 string. Returns NULL on failure. */ -static inline xmlDocPtr htmlReadMemory5(const char *buffer, int size, - const char *url, const char *encoding, - int options) { - (void)size; (void)url; (void)encoding; (void)options; - return xmloxide_parse_html5(buffer); -} - -/** Free a document. */ -static inline void xmlFreeDoc(xmlDocPtr doc) { - xmloxide_free_doc(doc); -} - -/* ======================================================================== - * Tree navigation — returns heap-allocated xmlNode (caller must free) - * ======================================================================== */ - -/** Allocate an xmlNode handle. Caller must free with xmlFreeNode(). */ -static inline xmlNodePtr xmloxide_compat_make_node(xmlDocPtr doc, uint32_t id) { - if (id == 0) return NULL; - xmlNodePtr node = (xmlNodePtr)malloc(sizeof(xmlNode)); - if (node) { - node->doc = doc; - node->id = id; - } - return node; -} - -/** Free an xmlNode handle. Does NOT remove the node from the tree. */ -static inline void xmlFreeNode(xmlNodePtr node) { - free(node); -} - -/** Get the root element. Caller must free the returned node with xmlFreeNode(). */ -static inline xmlNodePtr xmlDocGetRootElement(xmlDocPtr doc) { - return xmloxide_compat_make_node(doc, xmloxide_doc_root_element(doc)); -} - -/** Get the parent node. Caller must free with xmlFreeNode(). */ -static inline xmlNodePtr xmlNodeGetParent(xmlNodePtr node) { - if (!node) return NULL; - return xmloxide_compat_make_node(node->doc, - xmloxide_node_parent(node->doc, node->id)); -} - -/** Get the first child. Caller must free with xmlFreeNode(). */ -static inline xmlNodePtr xmlNodeGetChildren(xmlNodePtr node) { - if (!node) return NULL; - return xmloxide_compat_make_node(node->doc, - xmloxide_node_first_child(node->doc, node->id)); -} - -/** Get the next sibling. Caller must free with xmlFreeNode(). */ -static inline xmlNodePtr xmlNodeGetNext(xmlNodePtr node) { - if (!node) return NULL; - return xmloxide_compat_make_node(node->doc, - xmloxide_node_next_sibling(node->doc, node->id)); -} - -/** Get the previous sibling. Caller must free with xmlFreeNode(). */ -static inline xmlNodePtr xmlNodeGetPrev(xmlNodePtr node) { - if (!node) return NULL; - return xmloxide_compat_make_node(node->doc, - xmloxide_node_prev_sibling(node->doc, node->id)); -} - -/* ======================================================================== - * Node inspection - * ======================================================================== */ - -/** Get the node type (returns XMLOXIDE_NODE_* constants). */ -static inline int xmlNodeGetType(xmlNodePtr node) { - if (!node) return -1; - return xmloxide_node_type(node->doc, node->id); -} - -/** - * Get the node name. Returns a caller-owned string that must be freed - * with xmlFree(). - */ -static inline char *xmlNodeGetName(xmlNodePtr node) { - if (!node) return NULL; - return xmloxide_node_name(node->doc, node->id); -} - -/** - * Get the concatenated text content. Returns a caller-owned string - * that must be freed with xmlFree(). - */ -static inline char *xmlNodeGetContent(xmlNodePtr node) { - if (!node) return NULL; - return xmloxide_node_text_content(node->doc, node->id); -} - -/** - * Get an attribute value by name. Returns a caller-owned string - * that must be freed with xmlFree(). - */ -static inline char *xmlGetProp(xmlNodePtr node, const char *name) { - if (!node) return NULL; - return xmloxide_node_attribute(node->doc, node->id, name); -} - -/** - * Set an attribute value. Returns 1 on success, 0 on failure. - */ -static inline int xmlSetProp(xmlNodePtr node, const char *name, - const char *value) { - if (!node) return 0; - return xmloxide_set_attribute(node->doc, node->id, name, value); -} - -/** - * Remove an attribute by name. Returns 1 if removed, 0 otherwise. - */ -static inline int xmlUnsetProp(xmlNodePtr node, const char *name) { - if (!node) return 0; - return xmloxide_remove_attribute(node->doc, node->id, name); -} - -/* ======================================================================== - * Serialization - * ======================================================================== */ - -/** - * Serialize a document to XML. The caller must free the result with xmlFree(). - * `mem` receives the string pointer, `size` receives the length. - */ -static inline void xmlDocDumpMemory(xmlDocPtr doc, char **mem, int *size) { - if (!doc || !mem) return; - char *s = xmloxide_serialize(doc); - *mem = s; - if (size) *size = s ? (int)strlen(s) : 0; -} - -/** - * Serialize a document to pretty-printed XML. The caller must free the - * result with xmlFree(). `mem` receives the string pointer, `size` the length. - */ -static inline void xmlDocDumpFormatMemory(xmlDocPtr doc, char **mem, - int *size, int format) { - (void)format; - if (!doc || !mem) return; - char *s = xmloxide_serialize_pretty(doc); - *mem = s; - if (size) *size = s ? (int)strlen(s) : 0; -} - -/* ======================================================================== - * String lifecycle - * ======================================================================== */ - -/** - * Free a string returned by xmloxide (replaces libxml2's xmlFree for strings). - */ -static inline void xmlFree(void *ptr) { - xmloxide_free_string((char *)ptr); -} - -/* ======================================================================== - * XPath - * ======================================================================== */ - -/** - * Evaluate an XPath expression. Returns NULL on failure. - * The result must be freed with xmlXPathFreeObject(). - */ -static inline xmlXPathObjectPtr xmlXPathEval(const char *expr, - xmlNodePtr context) { - if (!context) return NULL; - return xmloxide_xpath_eval(context->doc, context->id, expr); -} - -/** Free an XPath result. */ -static inline void xmlXPathFreeObject(xmlXPathObjectPtr obj) { - xmloxide_xpath_free_result(obj); -} - -/** Get the number of nodes in an XPath nodeset result. */ -static inline int xmlXPathNodeSetGetLength(xmlXPathObjectPtr obj) { - return (int)xmloxide_xpath_nodeset_count(obj); -} - -/** - * Get a node from an XPath nodeset by index. - * NOTE: Unlike libxml2, this requires the original document pointer. - * The returned node must be freed with xmlFreeNode(). - */ -static inline xmlNodePtr xmlXPathNodeSetItem(xmlXPathObjectPtr obj, - int index, - xmlDocPtr doc) { - uint32_t id = xmloxide_xpath_nodeset_item(obj, (size_t)index); - return xmloxide_compat_make_node(doc, id); -} - -/* ======================================================================== - * Error handling - * ======================================================================== */ - -/** Get the last error message. Library-owned — do NOT free. */ -static inline const char *xmlGetLastError(void) { - return xmloxide_last_error(); -} - -/** No-op. xmloxide uses thread-local error storage, not callbacks. */ -static inline void xmlSetGenericErrorFunc(void *ctx, - void (*handler)(void *, const char *, ...)) { - (void)ctx; (void)handler; -} - -#ifdef __cplusplus -} -#endif - -#endif /* LIBXML2_COMPAT_H */ diff --git a/browser/vendor/xmloxide/include/xmloxide.h b/browser/vendor/xmloxide/include/xmloxide.h deleted file mode 100644 index 478cca230..000000000 --- a/browser/vendor/xmloxide/include/xmloxide.h +++ /dev/null @@ -1,1007 +0,0 @@ -/* - * xmloxide.h — C API for xmloxide - * - * A memory-safe XML parsing library implemented in Rust. - * - * All returned strings are caller-owned and must be freed with - * xmloxide_free_string(). Document and XPath result pointers must be - * freed with their respective free functions. - * - * Error handling: functions that can fail return NULL (for pointers) - * or 0 (for node ids). Call xmloxide_last_error() to retrieve the - * error message for the most recent failure on the current thread. - * - * Thread safety: Unlike libxml2, xmloxide requires no global - * initialization or cleanup. Each document is independent and may be - * used from any thread. The last-error message is stored in thread-local - * storage, so each thread has its own error state. A single document - * must not be accessed concurrently from multiple threads without - * external synchronization. - */ - -#ifndef XMLOXIDE_H -#define XMLOXIDE_H - -#include <stddef.h> -#include <stdint.h> - -#ifdef __cplusplus -extern "C" { -#endif - -/* ---------- Opaque types ---------- */ - -/** Opaque XML document handle. */ -typedef struct xmloxide_document xmloxide_document; - -/** Opaque XPath result handle. */ -typedef struct xmloxide_xpath_value xmloxide_xpath_value; - -/** Opaque DTD handle. */ -typedef struct xmloxide_dtd xmloxide_dtd; - -/** Opaque RelaxNG schema handle. */ -typedef struct xmloxide_relaxng_schema xmloxide_relaxng_schema; - -/** Opaque XSD schema handle. */ -typedef struct xmloxide_xsd_schema xmloxide_xsd_schema; - -/** Opaque Schematron schema handle. */ -typedef struct xmloxide_schematron_schema xmloxide_schematron_schema; - -/** Opaque validation result handle. */ -typedef struct xmloxide_validation_result xmloxide_validation_result; - -/** Opaque XML Catalog handle. */ -typedef struct xmloxide_catalog xmloxide_catalog; - -/** Opaque push parser handle. */ -typedef struct xmloxide_push_parser xmloxide_push_parser; - -/** Opaque XML reader handle. */ -typedef struct xmloxide_reader xmloxide_reader; - -/* ---------- Node type constants ---------- */ - -#define XMLOXIDE_NODE_ELEMENT 1 -#define XMLOXIDE_NODE_TEXT 3 -#define XMLOXIDE_NODE_CDATA 4 -#define XMLOXIDE_NODE_ENTITY_REF 5 -#define XMLOXIDE_NODE_PI 7 -#define XMLOXIDE_NODE_COMMENT 8 -#define XMLOXIDE_NODE_DOCUMENT 9 -#define XMLOXIDE_NODE_DOCUMENT_TYPE 10 - -/* ---------- XPath result type constants ---------- */ - -#define XMLOXIDE_XPATH_NODESET 1 -#define XMLOXIDE_XPATH_BOOLEAN 2 -#define XMLOXIDE_XPATH_NUMBER 3 -#define XMLOXIDE_XPATH_STRING 4 - -/* ---------- Error severity constants ---------- */ - -#define XMLOXIDE_ERR_WARNING 0 -#define XMLOXIDE_ERR_ERROR 1 -#define XMLOXIDE_ERR_FATAL 2 - -/* ---------- Error handling ---------- */ - -/** - * Returns the last error message, or NULL if no error occurred. - * - * The returned string is owned by the library and must NOT be freed. - * It is valid until the next xmloxide FFI call on the same thread. - */ -const char *xmloxide_last_error(void); - -/** - * Returns the line number where the last error occurred, or 0 if unknown. - */ -uint32_t xmloxide_last_error_line(void); - -/** - * Returns the column number where the last error occurred, or 0 if unknown. - */ -uint32_t xmloxide_last_error_column(void); - -/** - * Returns the severity of the last error. - * Returns XMLOXIDE_ERR_WARNING (0), XMLOXIDE_ERR_ERROR (1), - * or XMLOXIDE_ERR_FATAL (2). Returns -1 if no error occurred. - */ -int32_t xmloxide_last_error_severity(void); - -/* ---------- Document lifecycle ---------- */ - -/** - * Parses a null-terminated UTF-8 XML string into a document. - * - * Returns a document pointer on success, or NULL on failure. - * The returned document must be freed with xmloxide_free_doc(). - */ -xmloxide_document *xmloxide_parse_str(const char *input); - -/** - * Parses raw bytes as XML, with automatic encoding detection. - * - * Returns a document pointer on success, or NULL on failure. - * The returned document must be freed with xmloxide_free_doc(). - */ -xmloxide_document *xmloxide_parse_bytes(const uint8_t *data, size_t len); - -/** - * Parses an HTML string into a document. - * - * Returns a document pointer on success, or NULL on failure. - * The returned document must be freed with xmloxide_free_doc(). - */ -xmloxide_document *xmloxide_parse_html(const char *input); - -/** - * Parses an HTML5 string using the WHATWG parsing algorithm. - * - * Returns a document pointer on success, or NULL on failure. - * The returned document must be freed with xmloxide_free_doc(). - */ -xmloxide_document *xmloxide_parse_html5(const char *input); - -/** - * Parses an HTML5 fragment with a context element (the innerHTML algorithm). - * - * context_element is the tag name of the context (e.g., "body", "div", "table"). - * Returns a document pointer on success, or NULL on failure. - * The returned document must be freed with xmloxide_free_doc(). - */ -xmloxide_document *xmloxide_parse_html5_fragment(const char *input, - const char *context_element); - -/** - * Parses an XML file from a filesystem path. - * - * Returns a document pointer on success, or NULL on failure. - * The returned document must be freed with xmloxide_free_doc(). - */ -xmloxide_document *xmloxide_parse_file(const char *path); - -/** - * Frees a document previously returned by a parse function. - * Passing NULL is safe and does nothing. - */ -void xmloxide_free_doc(xmloxide_document *doc); - -/* ---------- Document properties ---------- */ - -/** - * Returns the XML version string (e.g., "1.0"), or NULL if not declared. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_doc_version(const xmloxide_document *doc); - -/** - * Returns the encoding string (e.g., "UTF-8"), or NULL if not declared. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_doc_encoding(const xmloxide_document *doc); - -/* ---------- Document diagnostics ---------- */ - -/** - * Returns the number of parse diagnostics (warnings + recovered errors) - * on a document. Returns 0 if the document has no diagnostics. - */ -size_t xmloxide_doc_diagnostic_count(const xmloxide_document *doc); - -/** - * Returns the error message of the diagnostic at the given index. - * Returns NULL if out of range. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_doc_diagnostic_message(const xmloxide_document *doc, size_t index); - -/** Returns the line number of the diagnostic at the given index (0 if unknown). */ -uint32_t xmloxide_doc_diagnostic_line(const xmloxide_document *doc, size_t index); - -/** Returns the column number of the diagnostic at the given index (0 if unknown). */ -uint32_t xmloxide_doc_diagnostic_column(const xmloxide_document *doc, size_t index); - -/** - * Returns the severity of the diagnostic at the given index. - * Returns XMLOXIDE_ERR_WARNING, XMLOXIDE_ERR_ERROR, or XMLOXIDE_ERR_FATAL. - * Returns -1 if out of range. - */ -int32_t xmloxide_doc_diagnostic_severity(const xmloxide_document *doc, size_t index); - -/* ---------- Tree navigation ---------- */ - -/* - * Node IDs are uint32_t values. A value of 0 means "no node" - * (invalid/missing). - */ - -/** Returns the document root node id. */ -uint32_t xmloxide_doc_root(const xmloxide_document *doc); - -/** Returns the root element of the document, or 0 if none. */ -uint32_t xmloxide_doc_root_element(const xmloxide_document *doc); - -/** Returns the parent of a node, or 0 if none. */ -uint32_t xmloxide_node_parent(const xmloxide_document *doc, uint32_t node); - -/** Returns the first child of a node, or 0 if none. */ -uint32_t xmloxide_node_first_child(const xmloxide_document *doc, uint32_t node); - -/** Returns the last child of a node, or 0 if none. */ -uint32_t xmloxide_node_last_child(const xmloxide_document *doc, uint32_t node); - -/** Returns the next sibling of a node, or 0 if none. */ -uint32_t xmloxide_node_next_sibling(const xmloxide_document *doc, uint32_t node); - -/** Returns the previous sibling of a node, or 0 if none. */ -uint32_t xmloxide_node_prev_sibling(const xmloxide_document *doc, uint32_t node); - -/* ---------- Node inspection ---------- */ - -/** - * Returns the node type as an integer constant. - * Returns -1 if the document or node is invalid. - */ -int32_t xmloxide_node_type(const xmloxide_document *doc, uint32_t node); - -/** - * Returns the name of a node (element local name or PI target). - * Returns NULL for node types that have no name. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_node_name(const xmloxide_document *doc, uint32_t node); - -/** - * Returns the direct text content of a text, comment, CDATA, or PI node. - * Returns NULL for element and document nodes. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_node_text(const xmloxide_document *doc, uint32_t node); - -/** - * Returns the concatenated text content of a node and all descendants. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_node_text_content(const xmloxide_document *doc, uint32_t node); - -/** - * Returns the namespace URI of an element node, or NULL if none. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_node_namespace(const xmloxide_document *doc, uint32_t node); - -/** - * Returns the namespace prefix of an element node (e.g., "svg" for <svg:rect>). - * Returns NULL if no prefix. The returned string must be freed with - * xmloxide_free_string(). - */ -char *xmloxide_node_prefix(const xmloxide_document *doc, uint32_t node); - -/** - * Returns the value of an attribute by name on an element node. - * Returns NULL if the attribute is not present. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_node_attribute(const xmloxide_document *doc, uint32_t node, - const char *name); - -/** - * Returns the number of attributes on an element node. - * Returns 0 for non-element nodes. - */ -size_t xmloxide_node_attribute_count(const xmloxide_document *doc, uint32_t node); - -/** - * Returns the name of the attribute at the given index. - * Returns NULL if the index is out of range. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_node_attribute_name_at(const xmloxide_document *doc, - uint32_t node, size_t index); - -/** - * Returns the value of the attribute at the given index. - * Returns NULL if the index is out of range. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_node_attribute_value_at(const xmloxide_document *doc, - uint32_t node, size_t index); - -/* ---------- Tree mutation ---------- */ - -/** - * Creates a new element node and returns its id (0 on failure). - * The node is detached — use xmloxide_append_child() to add it to the tree. - */ -uint32_t xmloxide_create_element(xmloxide_document *doc, const char *name); - -/** - * Creates a new text node and returns its id (0 on failure). - */ -uint32_t xmloxide_create_text(xmloxide_document *doc, const char *content); - -/** - * Creates a new comment node and returns its id (0 on failure). - */ -uint32_t xmloxide_create_comment(xmloxide_document *doc, const char *content); - -/** - * Appends a child node to a parent. Returns 1 on success, 0 on failure. - */ -int32_t xmloxide_append_child(xmloxide_document *doc, uint32_t parent, - uint32_t child); - -/** - * Removes a node from the tree. Returns 1 on success, 0 on failure. - * The node remains in the arena but is detached from the tree. - */ -int32_t xmloxide_remove_node(xmloxide_document *doc, uint32_t node); - -/** - * Clones a node (and optionally its descendants). Returns the new node id. - * Set deep=1 for a deep clone, deep=0 for a shallow clone. - * Returns 0 on failure. - */ -uint32_t xmloxide_clone_node(xmloxide_document *doc, uint32_t node, int32_t deep); - -/** - * Sets an attribute on an element node. Returns 1 on success, 0 on failure. - * If the attribute already exists, its value is updated. - */ -int32_t xmloxide_set_attribute(xmloxide_document *doc, uint32_t node, - const char *name, const char *value); - -/** - * Sets the text content of a node. Returns 1 on success, 0 on failure. - * For text/CDATA/comment nodes, updates content directly. - * For element nodes, removes all children and replaces with a text node. - */ -int32_t xmloxide_set_text_content(xmloxide_document *doc, uint32_t node, - const char *content); - -/** - * Inserts a node before a reference sibling. Returns 1 on success, 0 on failure. - */ -int32_t xmloxide_insert_before(xmloxide_document *doc, uint32_t reference, - uint32_t new_child); - -/** - * Inserts a node after a reference sibling. Returns 1 on success, 0 on failure. - */ -int32_t xmloxide_insert_after(xmloxide_document *doc, uint32_t reference, - uint32_t new_child); - -/** - * Replaces a node in the tree with another. Returns 1 on success, 0 on failure. - * The old node is detached and the new node takes its position. - */ -int32_t xmloxide_replace_node(xmloxide_document *doc, uint32_t old_node, - uint32_t new_node); - -/** - * Removes an attribute by name from an element node. - * Returns 1 if removed, 0 if not found or not an element. - */ -int32_t xmloxide_remove_attribute(xmloxide_document *doc, uint32_t node, - const char *name); - -/** - * Creates a new processing instruction node and returns its id (0 on failure). - * data may be NULL. - */ -uint32_t xmloxide_create_pi(xmloxide_document *doc, const char *target, - const char *data); - -/** - * Renames an element node. Returns 1 on success, 0 on failure. - */ -int32_t xmloxide_rename_element(xmloxide_document *doc, uint32_t node, - const char *new_name); - -/** - * Returns the element with the given ID attribute, or 0 if not found. - * The document's id_map must be populated first (typically via DTD validation). - */ -uint32_t xmloxide_element_by_id(const xmloxide_document *doc, const char *id); - -/* ---------- Serialization ---------- */ - -/** - * Serializes a document to an XML string. - * Returns a caller-owned C string that must be freed with - * xmloxide_free_string(). Returns NULL on failure. - */ -char *xmloxide_serialize(const xmloxide_document *doc); - -/** - * Serializes a document to a pretty-printed XML string with two-space indent. - * Returns a caller-owned C string that must be freed with - * xmloxide_free_string(). Returns NULL on failure. - */ -char *xmloxide_serialize_pretty(const xmloxide_document *doc); - -/** - * Serializes a document to a pretty-printed XML string with a custom indent. - * indent_str is the string used for each level (e.g., "\t" or " "). - * Returns a caller-owned C string that must be freed with - * xmloxide_free_string(). Returns NULL on failure. - */ -char *xmloxide_serialize_pretty_custom(const xmloxide_document *doc, - const char *indent_str); - -/** - * Serializes a document to an HTML string. - * Returns a caller-owned C string that must be freed with - * xmloxide_free_string(). Returns NULL on failure. - */ -char *xmloxide_serialize_html(const xmloxide_document *doc); - -/** - * Serializes a document to an HTML5 string (WHATWG algorithm). - * Returns a caller-owned C string that must be freed with - * xmloxide_free_string(). Returns NULL on failure. - */ -char *xmloxide_serialize_html5(const xmloxide_document *doc); - -/* ---------- Validation ---------- */ - -/** - * Parses a DTD from a null-terminated UTF-8 string. - * Returns a DTD pointer on success, or NULL on failure. - * The returned DTD must be freed with xmloxide_free_dtd(). - */ -xmloxide_dtd *xmloxide_parse_dtd(const char *input); - -/** Frees a DTD. Passing NULL is safe and does nothing. */ -void xmloxide_free_dtd(xmloxide_dtd *dtd); - -/** - * Validates a document against a DTD. - * Note: DTD validation may populate the document's id_map (requires mutable doc). - * Returns a validation result that must be freed with - * xmloxide_free_validation_result(). - */ -xmloxide_validation_result *xmloxide_validate_dtd(xmloxide_document *doc, - const xmloxide_dtd *dtd); - -/** - * Parses a RelaxNG schema from a null-terminated UTF-8 XML string. - * Returns a schema pointer on success, or NULL on failure. - * The returned schema must be freed with xmloxide_free_relaxng(). - */ -xmloxide_relaxng_schema *xmloxide_parse_relaxng(const char *input); - -/** Frees a RelaxNG schema. Passing NULL is safe and does nothing. */ -void xmloxide_free_relaxng(xmloxide_relaxng_schema *schema); - -/** - * Validates a document against a RelaxNG schema. - * Returns a validation result that must be freed with - * xmloxide_free_validation_result(). - */ -xmloxide_validation_result *xmloxide_validate_relaxng(const xmloxide_document *doc, - const xmloxide_relaxng_schema *schema); - -/** - * Parses an XSD schema from a null-terminated UTF-8 XML string. - * Returns a schema pointer on success, or NULL on failure. - * The returned schema must be freed with xmloxide_free_xsd(). - */ -xmloxide_xsd_schema *xmloxide_parse_xsd(const char *input); - -/** Frees an XSD schema. Passing NULL is safe and does nothing. */ -void xmloxide_free_xsd(xmloxide_xsd_schema *schema); - -/** - * Validates a document against an XSD schema. - * Returns a validation result that must be freed with - * xmloxide_free_validation_result(). - */ -xmloxide_validation_result *xmloxide_validate_xsd(const xmloxide_document *doc, - const xmloxide_xsd_schema *schema); - -/** - * Parses an ISO Schematron schema from a null-terminated UTF-8 XML string. - * Returns a schema pointer on success, or NULL on failure. - * The returned schema must be freed with xmloxide_free_schematron(). - */ -xmloxide_schematron_schema *xmloxide_parse_schematron(const char *input); - -/** Frees a Schematron schema. Passing NULL is safe and does nothing. */ -void xmloxide_free_schematron(xmloxide_schematron_schema *schema); - -/** - * Validates a document against an ISO Schematron schema. - * Returns a validation result that must be freed with - * xmloxide_free_validation_result(). - */ -xmloxide_validation_result *xmloxide_validate_schematron( - const xmloxide_document *doc, - const xmloxide_schematron_schema *schema); - -/** - * Validates a document against a Schematron schema using a specific phase. - * phase is the name of the phase to activate (NULL for all patterns). - * Returns a validation result that must be freed with - * xmloxide_free_validation_result(). - */ -xmloxide_validation_result *xmloxide_validate_schematron_with_phase( - const xmloxide_document *doc, - const xmloxide_schematron_schema *schema, - const char *phase); - -/** - * Returns whether the validation result indicates a valid document. - * Returns 1 for valid, 0 for invalid or NULL. - */ -int32_t xmloxide_validation_is_valid(const xmloxide_validation_result *result); - -/** - * Returns the number of validation errors. - * Returns 0 if the result is NULL. - */ -size_t xmloxide_validation_error_count(const xmloxide_validation_result *result); - -/** - * Returns the error message at the given index. - * Returns NULL if the index is out of range. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_validation_error_message(const xmloxide_validation_result *result, - size_t index); - -/** - * Returns the number of validation warnings. - * Returns 0 if the result is NULL. - */ -size_t xmloxide_validation_warning_count(const xmloxide_validation_result *result); - -/** - * Returns the warning message at the given index. - * Returns NULL if the index is out of range. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_validation_warning_message(const xmloxide_validation_result *result, - size_t index); - -/** - * Frees a validation result. Passing NULL is safe and does nothing. - */ -void xmloxide_free_validation_result(xmloxide_validation_result *result); - -/* ---------- XPath ---------- */ - -/** - * Evaluates an XPath expression against a context node. - * - * Returns a pointer to the result on success, or NULL on failure. - * Use context_node=0 to use the document root as context. - * The returned result must be freed with xmloxide_xpath_free_result(). - */ -xmloxide_xpath_value *xmloxide_xpath_eval(const xmloxide_document *doc, - uint32_t context_node, - const char *expr); - -/** - * Returns the type of an XPath result. - * Returns one of the XMLOXIDE_XPATH_* constants, or -1 on error. - */ -int32_t xmloxide_xpath_result_type(const xmloxide_xpath_value *result); - -/** - * Returns the boolean value of an XPath result. - * Converts non-boolean results using XPath type coercion rules. - */ -int32_t xmloxide_xpath_result_boolean(const xmloxide_xpath_value *result); - -/** - * Returns the numeric value of an XPath result. - * Converts non-number results using XPath type coercion rules. - */ -double xmloxide_xpath_result_number(const xmloxide_xpath_value *result); - -/** - * Returns the string value of an XPath result. - * Converts non-string results using XPath type coercion rules. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_xpath_result_string(const xmloxide_xpath_value *result); - -/** Returns the number of nodes in an XPath nodeset result. */ -size_t xmloxide_xpath_nodeset_count(const xmloxide_xpath_value *result); - -/** - * Returns the node id at the given index in an XPath nodeset result. - * For attribute nodes, returns the id of the owner element (use the - * item_is_attribute / item_attr_name / item_attr_value accessors to - * inspect the attribute itself). - * Returns 0 if the result is not a nodeset or the index is out of bounds. - */ -uint32_t xmloxide_xpath_nodeset_item(const xmloxide_xpath_value *result, - size_t index); - -/** - * Returns 1 if the nodeset entry at the given index is an attribute node, - * 0 otherwise (including out-of-bounds and non-nodeset results). - */ -int xmloxide_xpath_nodeset_item_is_attribute(const xmloxide_xpath_value *result, - size_t index); - -/** - * Returns the qualified name (prefix:local) of the attribute at the given - * index in a nodeset result, or NULL if the entry is not an attribute. - * `doc` must be the document the result was evaluated against. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_xpath_nodeset_item_attr_name(const xmloxide_document *doc, - const xmloxide_xpath_value *result, - size_t index); - -/** - * Returns the value of the attribute at the given index in a nodeset - * result, or NULL if the entry is not an attribute. - * `doc` must be the document the result was evaluated against. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_xpath_nodeset_item_attr_value(const xmloxide_document *doc, - const xmloxide_xpath_value *result, - size_t index); - -/** - * Frees an XPath result previously returned by xmloxide_xpath_eval(). - * Passing NULL is safe and does nothing. - */ -void xmloxide_xpath_free_result(xmloxide_xpath_value *result); - -/* ---------- Canonical XML (C14N) ---------- */ - -/** - * Canonicalizes a document using inclusive C14N with comments. - * Returns a caller-owned C string that must be freed with - * xmloxide_free_string(). Returns NULL on failure. - */ -char *xmloxide_canonicalize(const xmloxide_document *doc); - -/** - * Canonicalizes a document with options. - * with_comments: 1 to include comments, 0 to strip. - * exclusive: 1 for exclusive C14N, 0 for inclusive. - */ -char *xmloxide_canonicalize_opts(const xmloxide_document *doc, - int32_t with_comments, int32_t exclusive); - -/** - * Canonicalizes a subtree rooted at the given node. - */ -char *xmloxide_canonicalize_subtree(const xmloxide_document *doc, - uint32_t node, int32_t with_comments, - int32_t exclusive); - -/* ---------- XInclude ---------- */ - -/** - * Processes XInclude elements in a document using file-based resolution. - * Returns the number of successful inclusions, or -1 on failure. - * Errors are stored in the thread-local error (retrievable via xmloxide_last_error). - */ -int32_t xmloxide_process_xincludes(xmloxide_document *doc); - -/* ---------- XML Catalogs ---------- */ - -/** - * Parses an XML Catalog from a null-terminated UTF-8 XML string. - * Returns a catalog pointer on success, or NULL on failure. - * The returned catalog must be freed with xmloxide_free_catalog(). - */ -xmloxide_catalog *xmloxide_parse_catalog(const char *input); - -/** Frees a catalog. Passing NULL is safe and does nothing. */ -void xmloxide_free_catalog(xmloxide_catalog *catalog); - -/** - * Resolves a system identifier using the catalog. - * Returns a caller-owned URI string, or NULL if not found. - */ -char *xmloxide_catalog_resolve_system(const xmloxide_catalog *catalog, - const char *system_id); - -/** - * Resolves a public identifier using the catalog. - * Returns a caller-owned URI string, or NULL if not found. - */ -char *xmloxide_catalog_resolve_public(const xmloxide_catalog *catalog, - const char *public_id); - -/** - * Resolves a URI using the catalog. - * Returns a caller-owned URI string, or NULL if not found. - */ -char *xmloxide_catalog_resolve_uri(const xmloxide_catalog *catalog, - const char *uri); - -/* ---------- Push parser (incremental) ---------- */ - -/** - * Creates a new push parser with default options. - * The returned parser must be consumed via xmloxide_push_parser_finish() - * or freed with xmloxide_push_parser_free(). - */ -xmloxide_push_parser *xmloxide_push_parser_new(void); - -/** - * Feeds a chunk of raw bytes into the push parser. - * Data can be split at arbitrary byte boundaries. - */ -void xmloxide_push_parser_push(xmloxide_push_parser *parser, - const uint8_t *data, size_t len); - -/** - * Finalizes parsing and returns the constructed document. - * - * This CONSUMES the parser — the parser pointer becomes invalid after - * this call. Do NOT call xmloxide_push_parser_free() after finish. - * - * Returns a document pointer on success, or NULL on failure. - * The returned document must be freed with xmloxide_free_doc(). - */ -xmloxide_document *xmloxide_push_parser_finish(xmloxide_push_parser *parser); - -/** - * Returns the number of bytes currently buffered in the push parser. - */ -size_t xmloxide_push_parser_buffered_bytes(const xmloxide_push_parser *parser); - -/** - * Resets the push parser, discarding all buffered data. - * The parser can then be reused for a new document. - */ -void xmloxide_push_parser_reset(xmloxide_push_parser *parser); - -/** - * Frees a push parser without finishing it. - * Use this to discard a parser whose data you no longer need. - * Passing NULL is safe. Do NOT call after finish(). - */ -void xmloxide_push_parser_free(xmloxide_push_parser *parser); - -/* ---------- XmlReader (pull-based streaming) ---------- */ - -/* - * Reader node type constants (matching libxml2's xmlReaderTypes). - */ -#define XMLOXIDE_READER_NONE 0 -#define XMLOXIDE_READER_ELEMENT 1 -#define XMLOXIDE_READER_ATTRIBUTE 2 -#define XMLOXIDE_READER_TEXT 3 -#define XMLOXIDE_READER_CDATA 4 -#define XMLOXIDE_READER_PI 7 -#define XMLOXIDE_READER_COMMENT 8 -#define XMLOXIDE_READER_DOCUMENT_TYPE 10 -#define XMLOXIDE_READER_WHITESPACE 13 -#define XMLOXIDE_READER_END_ELEMENT 15 -#define XMLOXIDE_READER_XML_DECLARATION 17 -#define XMLOXIDE_READER_END_DOCUMENT (-1) - -/** - * Creates a new XmlReader from a null-terminated UTF-8 string. - * Returns an opaque reader pointer, or NULL on failure. - * The reader must be freed with xmloxide_reader_free(). - */ -xmloxide_reader *xmloxide_reader_new(const char *input); - -/** - * Advances the reader to the next node. - * Returns 1 if a node was read, 0 at end of document, -1 on error. - */ -int32_t xmloxide_reader_read(xmloxide_reader *reader); - -/** - * Returns the node type of the current node. - * Returns one of the XMLOXIDE_READER_* constants. - */ -int32_t xmloxide_reader_node_type(const xmloxide_reader *reader); - -/** - * Returns the qualified name of the current node, or NULL. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_reader_name(const xmloxide_reader *reader); - -/** - * Returns the local name of the current node (without prefix), or NULL. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_reader_local_name(const xmloxide_reader *reader); - -/** - * Returns the namespace prefix of the current node, or NULL. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_reader_prefix(const xmloxide_reader *reader); - -/** - * Returns the namespace URI of the current node, or NULL. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_reader_namespace_uri(const xmloxide_reader *reader); - -/** - * Returns the value of the current node (text, comment, attribute value), - * or NULL for elements and end elements. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_reader_value(const xmloxide_reader *reader); - -/** Returns the depth of the current node in the document tree. */ -uint32_t xmloxide_reader_depth(const xmloxide_reader *reader); - -/** - * Returns 1 if the current element is self-closing (empty), 0 otherwise. - */ -int32_t xmloxide_reader_is_empty_element(const xmloxide_reader *reader); - -/** - * Returns 1 if the current node has a value, 0 otherwise. - */ -int32_t xmloxide_reader_has_value(const xmloxide_reader *reader); - -/** Returns the number of attributes on the current element. */ -size_t xmloxide_reader_attribute_count(const xmloxide_reader *reader); - -/** - * Returns the value of an attribute by name on the current element, or NULL. - * The returned string must be freed with xmloxide_free_string(). - */ -char *xmloxide_reader_get_attribute(const xmloxide_reader *reader, - const char *name); - -/** - * Moves the reader to the first attribute of the current element. - * Returns 1 if successful, 0 if no attributes or not on an element. - */ -int32_t xmloxide_reader_move_to_first_attribute(xmloxide_reader *reader); - -/** - * Moves the reader to the next attribute. - * Returns 1 if successful, 0 if no more attributes. - */ -int32_t xmloxide_reader_move_to_next_attribute(xmloxide_reader *reader); - -/** - * Moves the reader back to the element from an attribute. - * Returns 1 if moved back, 0 if not on an attribute. - */ -int32_t xmloxide_reader_move_to_element(xmloxide_reader *reader); - -/** - * Frees a reader. Passing NULL is safe and does nothing. - */ -void xmloxide_reader_free(xmloxide_reader *reader); - -/* ---------- SAX2 streaming parser ---------- */ - -/** - * C function pointer type for start_element events. - * - * Parameters: - * local_name - element local name (never NULL) - * prefix - namespace prefix (may be NULL) - * namespace - namespace URI (may be NULL) - * attr_names - array of attribute name strings - * attr_values - array of attribute value strings - * attr_count - number of attributes - * user_data - opaque pointer passed through from the handler - */ -typedef void (*xmloxide_sax_start_element_cb)( - const char *local_name, const char *prefix, const char *namespace_uri, - const char *const *attr_names, const char *const *attr_values, - size_t attr_count, void *user_data); - -/** - * C function pointer type for end_element events. - * - * Parameters: - * local_name - element local name (never NULL) - * prefix - namespace prefix (may be NULL) - * namespace - namespace URI (may be NULL) - * user_data - opaque pointer passed through from the handler - */ -typedef void (*xmloxide_sax_end_element_cb)(const char *local_name, - const char *prefix, - const char *namespace_uri, - void *user_data); - -/** - * C function pointer type for characters, CDATA, and comment events. - * - * Parameters: - * content - text content (never NULL) - * user_data - opaque pointer passed through from the handler - */ -typedef void (*xmloxide_sax_text_cb)(const char *content, void *user_data); - -/** - * C function pointer type for processing instruction events. - * - * Parameters: - * target - PI target (never NULL) - * data - PI data (may be NULL) - * user_data - opaque pointer passed through from the handler - */ -typedef void (*xmloxide_sax_pi_cb)(const char *target, const char *data, - void *user_data); - -/** - * SAX handler with C function pointer callbacks. - * - * Set any callback to NULL to ignore that event type. - * user_data is passed through to every callback. - */ -typedef struct { - xmloxide_sax_start_element_cb start_element; - xmloxide_sax_end_element_cb end_element; - xmloxide_sax_text_cb characters; - xmloxide_sax_text_cb cdata; - xmloxide_sax_text_cb comment; - xmloxide_sax_pi_cb processing_instruction; - void *user_data; -} xmloxide_sax_handler; - -/** - * Parses XML with SAX streaming, dispatching events to C function pointers. - * - * xml must be a valid null-terminated UTF-8 C string. - * handler must point to a valid xmloxide_sax_handler struct. - * - * Returns 0 on success, -1 on error. Use xmloxide_last_error() for details. - */ -int32_t xmloxide_sax_parse(const char *xml, - const xmloxide_sax_handler *handler); - -/* ---------- CSS selectors ---------- */ - -/** - * Evaluates a CSS selector against a subtree and returns matching node IDs. - * - * scope is the node to search within (typically the root element). - * selector is a null-terminated CSS selector string (e.g., "div.class > p"). - * - * On success, sets *out_count to the number of matching nodes and returns - * a heap-allocated array of node IDs. The caller must free the array with - * xmloxide_free_nodeid_array(ptr, count). - * - * Returns NULL on failure (invalid selector or null arguments). - */ -uint32_t *xmloxide_css_select(const xmloxide_document *doc, uint32_t scope, - const char *selector, size_t *out_count); - -/** - * Frees a node ID array returned by xmloxide_css_select(). - * Passing NULL is safe and does nothing. - */ -void xmloxide_free_nodeid_array(uint32_t *ptr, size_t count); - -/** - * Returns the first node matching a CSS selector, or 0 if none found. - * This is a convenience wrapper around xmloxide_css_select(). - */ -uint32_t xmloxide_css_select_first(const xmloxide_document *doc, uint32_t scope, - const char *selector); - -/* ---------- String lifecycle ---------- */ - -/** - * Frees a string previously returned by an xmloxide FFI function. - * Passing NULL is safe and does nothing. - */ -void xmloxide_free_string(char *ptr); - -#ifdef __cplusplus -} -#endif - -#endif /* XMLOXIDE_H */ diff --git a/browser/vendor/xmloxide/src/async_xml.rs b/browser/vendor/xmloxide/src/async_xml.rs deleted file mode 100644 index 29028811d..000000000 --- a/browser/vendor/xmloxide/src/async_xml.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! Async XML parsing via `tokio::io::AsyncRead`. -//! -//! This module provides [`parse_async`], which reads from any `AsyncRead` -//! source and builds a [`Document`] using the push parser internally. -//! -//! Requires the `async` feature. -//! -//! # Examples -//! -//! ```no_run -//! # #[cfg(feature = "async")] -//! # async fn example() -> Result<(), Box<dyn std::error::Error>> { -//! use xmloxide::async_xml::parse_async; -//! -//! let file = tokio::fs::File::open("data.xml").await?; -//! let doc = parse_async(file).await?; -//! let root = doc.root_element().unwrap(); -//! println!("Root: {:?}", doc.node_name(root)); -//! # Ok(()) -//! # } -//! ``` - -use tokio::io::{AsyncRead, AsyncReadExt}; - -use crate::error::ParseError; -use crate::parser::{ParseOptions, PushParser}; -use crate::tree::Document; - -/// Default buffer size for async reads (8 KiB). -const DEFAULT_BUF_SIZE: usize = 8192; - -/// Parses XML from an `AsyncRead` source using default options. -/// -/// Reads the source in chunks and feeds them to the push parser. -/// -/// # Errors -/// -/// Returns a `ParseError` if the XML is malformed. -pub async fn parse_async<R: AsyncRead + Unpin>(reader: R) -> Result<Document, ParseError> { - parse_async_with_options(reader, ParseOptions::default()).await -} - -/// Parses XML from an `AsyncRead` source with the given parse options. -/// -/// # Errors -/// -/// Returns a `ParseError` if the XML is malformed. -pub async fn parse_async_with_options<R: AsyncRead + Unpin>( - mut reader: R, - options: ParseOptions, -) -> Result<Document, ParseError> { - let mut parser = PushParser::with_options(options); - let mut buf = vec![0u8; DEFAULT_BUF_SIZE]; - - loop { - let n = reader.read(&mut buf).await.map_err(|e| ParseError { - message: format!("I/O error: {e}"), - location: crate::error::SourceLocation { - line: 0, - column: 0, - byte_offset: 0, - }, - diagnostics: vec![], - })?; - if n == 0 { - break; - } - parser.push(&buf[..n]); - } - - parser.finish() -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_parse_async_from_bytes() { - let data = b"<root><child>Hello</child></root>"; - let cursor = std::io::Cursor::new(data); - let doc = parse_async(cursor).await.unwrap(); - let root = doc.root_element().unwrap(); - assert_eq!(doc.node_name(root), Some("root")); - assert_eq!(doc.text_content(root), "Hello"); - } - - #[tokio::test] - async fn test_parse_async_empty_document() { - let data = b"<root/>"; - let cursor = std::io::Cursor::new(data); - let doc = parse_async(cursor).await.unwrap(); - let root = doc.root_element().unwrap(); - assert_eq!(doc.node_name(root), Some("root")); - } - - #[tokio::test] - async fn test_parse_async_with_options() { - let data = b"<root><child>text</child></root>"; - let cursor = std::io::Cursor::new(data); - let opts = ParseOptions::default().recover(true); - let doc = parse_async_with_options(cursor, opts).await.unwrap(); - let root = doc.root_element().unwrap(); - assert_eq!(doc.node_name(root), Some("root")); - } - - #[tokio::test] - async fn test_parse_async_malformed() { - let data = b"<root><</root>"; - let cursor = std::io::Cursor::new(data); - let result = parse_async(cursor).await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_parse_async_small_reads() { - // Simulate a reader that yields one byte at a time - let data = b"<root>Hello</root>"; - let cursor = SlowReader { data, pos: 0 }; - let doc = parse_async(cursor).await.unwrap(); - let root = doc.root_element().unwrap(); - assert_eq!(doc.text_content(root), "Hello"); - } - - /// Test reader that yields one byte at a time. - struct SlowReader { - data: &'static [u8], - pos: usize, - } - - impl AsyncRead for SlowReader { - fn poll_read( - mut self: std::pin::Pin<&mut Self>, - _cx: &mut std::task::Context<'_>, - buf: &mut tokio::io::ReadBuf<'_>, - ) -> std::task::Poll<std::io::Result<()>> { - if self.pos >= self.data.len() { - return std::task::Poll::Ready(Ok(())); - } - buf.put_slice(&self.data[self.pos..=self.pos]); - self.pos += 1; - std::task::Poll::Ready(Ok(())) - } - } -} diff --git a/browser/vendor/xmloxide/src/bin/xmllint.rs b/browser/vendor/xmloxide/src/bin/xmllint.rs deleted file mode 100644 index 2e1f71e6b..000000000 --- a/browser/vendor/xmloxide/src/bin/xmllint.rs +++ /dev/null @@ -1,923 +0,0 @@ -//! xmllint-compatible CLI tool for XML/HTML processing. -//! -//! Provides the most commonly used features of libxml2's `xmllint` command: -//! parsing, validation, `XPath` evaluation, canonical XML output, and more. - -use std::fmt::Write as _; -use std::fs; -use std::io::{self, Read, Write}; -use std::process::ExitCode; -use std::time::Instant; - -use clap::Parser; - -use xmloxide::html::parse_html_with_options; -use xmloxide::html5::parse_html5; -use xmloxide::parser::{self, ParseOptions}; -use xmloxide::serial::c14n::{canonicalize, C14nOptions}; -use xmloxide::serial::serialize; -use xmloxide::tree::{Document, NodeId, NodeKind}; -use xmloxide::xpath; - -// --------------------------------------------------------------------------- -// CLI argument definitions -// --------------------------------------------------------------------------- - -/// xmllint -- parse, validate, and process XML/HTML files. -/// -/// A Rust reimplementation of libxml2's xmllint, powered by xmloxide. -#[derive(Parser, Debug)] -#[command(name = "xmllint", version, about, long_about = None)] -#[allow(clippy::struct_excessive_bools)] -struct Cli { - /// XML files to process (use `-` for stdin). - #[arg(required = true)] - files: Vec<String>, - - /// Print additional information during processing. - #[arg(long)] - verbose: bool, - - // -- Parsing options --------------------------------------------------- - /// Parse input as HTML 4.01 instead of XML. - #[arg(long)] - html: bool, - - /// Parse input as HTML5 (WHATWG) instead of XML. - #[arg(long)] - html5: bool, - - /// Recover from parsing errors (produce partial tree). - #[arg(long)] - recover: bool, - - /// Remove ignorable blank (whitespace-only) text nodes. - #[arg(long)] - noblanks: bool, - - /// Do not output the result tree. - #[arg(long)] - noout: bool, - - /// Output in the given encoding (e.g., UTF-8, ISO-8859-1). - #[arg(long, value_name = "ENCODING")] - encode: Option<String>, - - // -- Validation options ------------------------------------------------ - /// Validate against the DTD declared in the document. - #[arg(long)] - valid: bool, - - /// Validate against an external DTD file. - #[arg(long, value_name = "FILE")] - dtdvalid: Option<String>, - - /// Validate against a RelaxNG schema file. - #[allow(clippy::doc_markdown)] - #[arg(long, value_name = "FILE")] - relaxng: Option<String>, - - /// Validate against an XML Schema (XSD) file. - #[arg(long, value_name = "FILE")] - schema: Option<String>, - - /// Validate against an ISO Schematron schema file. - #[arg(long, value_name = "FILE")] - schematron: Option<String>, - - // -- XPath ------------------------------------------------------------- - /// Evaluate an XPath expression and print the result. - #[allow(clippy::doc_markdown)] - #[arg(long, value_name = "EXPR")] - xpath: Option<String>, - - // -- Output options ---------------------------------------------------- - /// Pretty-print (indent) the output. - #[arg(long)] - format: bool, - - /// Canonical XML (C14N 1.0) output. - #[arg(long)] - c14n: bool, - - /// Exclusive Canonical XML output. - #[arg(long = "exc-c14n")] - exc_c14n: bool, - - /// Save output to a file instead of stdout. - #[arg(long, value_name = "FILE")] - output: Option<String>, - - // -- Debug options ----------------------------------------------------- - /// Print a debug representation of the document tree. - #[arg(long)] - debug: bool, - - /// Print timing information for parsing and processing. - #[arg(long)] - timing: bool, -} - -// --------------------------------------------------------------------------- -// Exit codes (matching libxml2 xmllint conventions) -// --------------------------------------------------------------------------- - -const EXIT_SUCCESS: u8 = 0; -const EXIT_PARSE_ERROR: u8 = 1; -const EXIT_VALIDATION_ERROR: u8 = 3; - -// --------------------------------------------------------------------------- -// Main entry point -// --------------------------------------------------------------------------- - -fn main() -> ExitCode { - let cli = Cli::parse(); - let mut worst_exit: u8 = EXIT_SUCCESS; - - for file in &cli.files { - let exit = process_file(&cli, file); - if exit > worst_exit { - worst_exit = exit; - } - } - - ExitCode::from(worst_exit) -} - -/// Processes a single input file and returns an exit code. -fn process_file(cli: &Cli, filename: &str) -> u8 { - // -- Read input -------------------------------------------------------- - let start_read = Instant::now(); - - let input = match read_input(filename) { - Ok(data) => data, - Err(e) => { - eprintln!("{filename}: failed to read: {e}"); - return EXIT_PARSE_ERROR; - } - }; - - if cli.timing { - let elapsed = start_read.elapsed(); - eprintln!("Reading file {filename} took {elapsed:?}"); - } - - // -- Parse ------------------------------------------------------------- - let start_parse = Instant::now(); - - let doc = if cli.html5 { - parse_as_html5(&input) - } else if cli.html { - parse_as_html(cli, &input) - } else { - parse_as_xml(cli, &input) - }; - - let mut doc = match doc { - Ok(d) => d, - Err(msg) => { - eprintln!("{filename}: {msg}"); - return EXIT_PARSE_ERROR; - } - }; - - if cli.timing { - let elapsed = start_parse.elapsed(); - eprintln!("Parsing took {elapsed:?}"); - } - - if cli.verbose && !doc.diagnostics.is_empty() { - for diag in &doc.diagnostics { - eprintln!("{filename}: {diag}"); - } - } - - // -- Validation -------------------------------------------------------- - let mut exit_code = EXIT_SUCCESS; - - if cli.valid { - let code = validate_dtd_internal(filename, &mut doc); - if code > exit_code { - exit_code = code; - } - } - - if let Some(ref dtd_file) = cli.dtdvalid { - let code = validate_dtd_external(filename, &mut doc, dtd_file); - if code > exit_code { - exit_code = code; - } - } - - if let Some(ref rng_file) = cli.relaxng { - let code = validate_relaxng_file(filename, &doc, rng_file); - if code > exit_code { - exit_code = code; - } - } - - if let Some(ref xsd_file) = cli.schema { - let code = validate_xsd_file(filename, &doc, xsd_file); - if code > exit_code { - exit_code = code; - } - } - - if let Some(ref sch_file) = cli.schematron { - let code = validate_schematron_file(filename, &doc, sch_file); - if code > exit_code { - exit_code = code; - } - } - - // -- XPath evaluation -------------------------------------------------- - if let Some(ref expr) = cli.xpath { - evaluate_xpath(filename, &doc, expr); - } - - // -- Debug tree -------------------------------------------------------- - if cli.debug { - let debug_output = format_debug_tree(&doc); - write_output(cli, &debug_output); - return exit_code; - } - - // -- Serialization / output -------------------------------------------- - if !cli.noout && cli.xpath.is_none() { - let start_serial = Instant::now(); - - let output_str = serialize_document(cli, &doc); - write_output(cli, &output_str); - - if cli.timing { - let elapsed = start_serial.elapsed(); - eprintln!("Serializing took {elapsed:?}"); - } - } - - exit_code -} - -// --------------------------------------------------------------------------- -// Input reading -// --------------------------------------------------------------------------- - -/// Reads input from a file or stdin (when filename is `-`). -fn read_input(filename: &str) -> io::Result<String> { - if filename == "-" { - let mut buf = String::new(); - io::stdin().read_to_string(&mut buf)?; - Ok(buf) - } else { - fs::read_to_string(filename) - } -} - -// --------------------------------------------------------------------------- -// Parsing -// --------------------------------------------------------------------------- - -/// Parses input as XML with the configured options. -fn parse_as_xml(cli: &Cli, input: &str) -> Result<Document, String> { - let opts = ParseOptions::default() - .recover(cli.recover) - .no_blanks(cli.noblanks); - parser::parse_str_with_options(input, &opts).map_err(|e| e.to_string()) -} - -/// Parses input as HTML 4.01 with the configured options. -fn parse_as_html(cli: &Cli, input: &str) -> Result<Document, String> { - let opts = xmloxide::html::HtmlParseOptions::default() - .recover(cli.recover) - .no_blanks(cli.noblanks); - parse_html_with_options(input, &opts).map_err(|e| e.to_string()) -} - -/// Parses input as HTML5 (WHATWG parsing algorithm). -fn parse_as_html5(input: &str) -> Result<Document, String> { - parse_html5(input).map_err(|e| e.to_string()) -} - -// --------------------------------------------------------------------------- -// Validation -// --------------------------------------------------------------------------- - -/// Validates a document against its internal DTD (--valid). -fn validate_dtd_internal(filename: &str, doc: &mut Document) -> u8 { - let dtd_text = extract_internal_dtd_subset(doc); - if dtd_text.is_empty() { - eprintln!("{filename}: no DTD found for validation"); - return EXIT_VALIDATION_ERROR; - } - - match xmloxide::validation::dtd::parse_dtd(&dtd_text) { - Ok(dtd) => { - let result = xmloxide::validation::dtd::validate(doc, &dtd); - print_validation_result(filename, &result) - } - Err(e) => { - eprintln!("{filename}: failed to parse DTD: {e}"); - EXIT_VALIDATION_ERROR - } - } -} - -/// Validates a document against an external DTD file (--dtdvalid). -fn validate_dtd_external(filename: &str, doc: &mut Document, dtd_file: &str) -> u8 { - let dtd_content = match fs::read_to_string(dtd_file) { - Ok(content) => content, - Err(e) => { - eprintln!("{dtd_file}: failed to read DTD: {e}"); - return EXIT_VALIDATION_ERROR; - } - }; - - match xmloxide::validation::dtd::parse_dtd(&dtd_content) { - Ok(dtd) => { - let result = xmloxide::validation::dtd::validate(doc, &dtd); - print_validation_result(filename, &result) - } - Err(e) => { - eprintln!("{dtd_file}: failed to parse DTD: {e}"); - EXIT_VALIDATION_ERROR - } - } -} - -/// Validates a document against a `RelaxNG` schema file (--relaxng). -fn validate_relaxng_file(filename: &str, doc: &Document, rng_file: &str) -> u8 { - let schema_content = match fs::read_to_string(rng_file) { - Ok(content) => content, - Err(e) => { - eprintln!("{rng_file}: failed to read RelaxNG schema: {e}"); - return EXIT_VALIDATION_ERROR; - } - }; - - match xmloxide::validation::relaxng::parse_relaxng(&schema_content) { - Ok(schema) => { - let result = xmloxide::validation::relaxng::validate(doc, &schema); - print_validation_result(filename, &result) - } - Err(e) => { - eprintln!("{rng_file}: failed to parse RelaxNG schema: {e}"); - EXIT_VALIDATION_ERROR - } - } -} - -/// Validates a document against an XML Schema (XSD) file (--schema). -fn validate_xsd_file(filename: &str, doc: &Document, xsd_file: &str) -> u8 { - let schema_content = match fs::read_to_string(xsd_file) { - Ok(content) => content, - Err(e) => { - eprintln!("{xsd_file}: failed to read XML Schema: {e}"); - return EXIT_VALIDATION_ERROR; - } - }; - - match xmloxide::validation::xsd::parse_xsd(&schema_content) { - Ok(schema) => { - let result = xmloxide::validation::xsd::validate_xsd(doc, &schema); - print_validation_result(filename, &result) - } - Err(e) => { - eprintln!("{xsd_file}: failed to parse XML Schema: {e}"); - EXIT_VALIDATION_ERROR - } - } -} - -/// Validates a document against an ISO Schematron schema file (--schematron). -fn validate_schematron_file(filename: &str, doc: &Document, sch_file: &str) -> u8 { - let schema_content = match fs::read_to_string(sch_file) { - Ok(content) => content, - Err(e) => { - eprintln!("{sch_file}: failed to read Schematron schema: {e}"); - return EXIT_VALIDATION_ERROR; - } - }; - - match xmloxide::validation::schematron::parse_schematron(&schema_content) { - Ok(schema) => { - let result = xmloxide::validation::schematron::validate_schematron(doc, &schema); - print_validation_result(filename, &result) - } - Err(e) => { - eprintln!("{sch_file}: failed to parse Schematron schema: {e}"); - EXIT_VALIDATION_ERROR - } - } -} - -/// Prints validation errors/warnings and returns the exit code. -fn print_validation_result(filename: &str, result: &xmloxide::validation::ValidationResult) -> u8 { - for warning in &result.warnings { - eprintln!("{filename}: validity warning: {warning}"); - } - for error in &result.errors { - eprintln!("{filename}: validity error: {error}"); - } - if result.is_valid { - eprintln!("{filename} validates"); - EXIT_SUCCESS - } else { - eprintln!("{filename} fails to validate"); - EXIT_VALIDATION_ERROR - } -} - -// --------------------------------------------------------------------------- -// XPath evaluation -// --------------------------------------------------------------------------- - -/// Evaluates an `XPath` expression and prints the result to stdout. -fn evaluate_xpath(filename: &str, doc: &Document, expression: &str) { - let context_node = doc.root_element().unwrap_or_else(|| doc.root()); - - match xpath::evaluate(doc, context_node, expression) { - Ok(value) => match &value { - xpath::XPathValue::NodeSet(nodes) => { - for &node in nodes { - match node { - xpath::XPathNode::Node(node_id) => { - let content = serialize_subtree(doc, node_id); - println!("{content}"); - } - xpath::XPathNode::Attribute { owner, index } => { - // Print attribute nodes as name="value" with the - // value XML-escaped, matching libxml2's xmllint. - if let Some(attr) = doc.attributes(owner).get(index as usize) { - let value = escape_attribute_value(&attr.value); - match &attr.prefix { - Some(prefix) => { - println!("{prefix}:{}=\"{value}\"", attr.name); - } - None => println!("{}=\"{value}\"", attr.name), - } - } - } - } - } - } - xpath::XPathValue::String(s) => { - println!("{s}"); - } - xpath::XPathValue::Number(n) => { - println!("{n}"); - } - xpath::XPathValue::Boolean(b) => { - println!("{b}"); - } - }, - Err(e) => { - eprintln!("{filename}: XPath error: {e}"); - } - } -} - -/// Serializes a single node and its subtree to XML. -/// Escapes an attribute value for `name="value"` output (XML 1.0 §2.3). -fn escape_attribute_value(value: &str) -> String { - let mut out = String::with_capacity(value.len()); - for ch in value.chars() { - match ch { - '&' => out.push_str("&amp;"), - '<' => out.push_str("&lt;"), - '>' => out.push_str("&gt;"), - '"' => out.push_str("&quot;"), - _ => out.push(ch), - } - } - out -} - -fn serialize_subtree(doc: &Document, node_id: NodeId) -> String { - let mut output = String::new(); - serialize_node_recursive(doc, node_id, &mut output); - output -} - -/// Recursively serializes a node to a string (for `XPath` output). -fn serialize_node_recursive(doc: &Document, id: NodeId, out: &mut String) { - match &doc.node(id).kind { - NodeKind::Element { - name, - prefix, - attributes, - .. - } => { - out.push('<'); - if let Some(pfx) = prefix { - out.push_str(pfx); - out.push(':'); - } - out.push_str(name); - for attr in attributes { - out.push(' '); - if let Some(pfx) = &attr.prefix { - out.push_str(pfx); - out.push(':'); - } - out.push_str(&attr.name); - out.push_str("=\""); - out.push_str(&attr.value); - out.push('"'); - } - if doc.first_child(id).is_none() { - out.push_str("/>"); - } else { - out.push('>'); - for child in doc.children(id) { - serialize_node_recursive(doc, child, out); - } - out.push_str("</"); - if let Some(pfx) = prefix { - out.push_str(pfx); - out.push(':'); - } - out.push_str(name); - out.push('>'); - } - } - NodeKind::Text { content } => { - out.push_str(content); - } - NodeKind::CData { content } => { - out.push_str("<![CDATA["); - out.push_str(content); - out.push_str("]]>"); - } - NodeKind::Comment { content } => { - out.push_str("<!--"); - out.push_str(content); - out.push_str("-->"); - } - NodeKind::ProcessingInstruction { target, data } => { - out.push_str("<?"); - out.push_str(target); - if let Some(d) = data { - out.push(' '); - out.push_str(d); - } - out.push_str("?>"); - } - NodeKind::EntityRef { name, .. } => { - out.push('&'); - out.push_str(name); - out.push(';'); - } - NodeKind::DocumentType { .. } | NodeKind::Document => {} - } -} - -// --------------------------------------------------------------------------- -// Serialization -// --------------------------------------------------------------------------- - -/// Serializes a document to string using the configured output mode. -fn serialize_document(cli: &Cli, doc: &Document) -> String { - if cli.c14n || cli.exc_c14n { - let opts = C14nOptions { - with_comments: true, - exclusive: cli.exc_c14n, - inclusive_prefixes: Vec::new(), - }; - let mut result = canonicalize(doc, &opts); - result.push('\n'); - result - } else { - let mut output = serialize(doc); - if cli.format { - output = pretty_print(&output); - } - if let Some(ref enc) = cli.encode { - output = update_encoding_declaration(&output, enc); - } - if !output.ends_with('\n') { - output.push('\n'); - } - output - } -} - -// --------------------------------------------------------------------------- -// Pretty printing -// --------------------------------------------------------------------------- - -/// Simple pretty-printer that adds newlines and indentation between tags. -/// -/// This operates on the serialized XML string, inserting newlines and -/// indentation at tag boundaries. It handles: -/// - Newlines after the XML declaration -/// - Indentation of nested elements -/// - Preserving text content inline with its parent element -fn pretty_print(xml: &str) -> String { - let mut result = String::with_capacity(xml.len() * 2); - let mut indent_level: usize = 0; - let indent_str = " "; - - // Split the XML into tokens: tags (starting with '<' and ending with '>') - // and text content between tags. - let tokens = tokenize_xml(xml); - - let mut i = 0; - while i < tokens.len() { - let token = &tokens[i]; - - if token.starts_with("<?") { - // Processing instruction / XML declaration - result.push_str(token); - result.push('\n'); - } else if token.starts_with("<!--") { - // Comment - push_indent(&mut result, indent_level, indent_str); - result.push_str(token); - result.push('\n'); - } else if token.starts_with("<!") { - // DOCTYPE or other declaration - push_indent(&mut result, indent_level, indent_str); - result.push_str(token); - result.push('\n'); - } else if token.starts_with("</") { - // Closing tag - indent_level = indent_level.saturating_sub(1); - push_indent(&mut result, indent_level, indent_str); - result.push_str(token); - result.push('\n'); - } else if token.starts_with('<') { - let extra = format_open_tag(&tokens, i, &mut result, &mut indent_level, indent_str); - i += extra; - } else { - // Text content on its own - let trimmed = token.trim(); - if !trimmed.is_empty() { - push_indent(&mut result, indent_level, indent_str); - result.push_str(trimmed); - result.push('\n'); - } - } - - i += 1; - } - - result -} - -/// Splits XML into tokens of tags and text content. -fn tokenize_xml(xml: &str) -> Vec<String> { - let mut tokens: Vec<String> = Vec::new(); - let mut current = String::new(); - - for ch in xml.chars() { - if ch == '<' { - if !current.is_empty() { - tokens.push(current.clone()); - current.clear(); - } - current.push(ch); - } else if ch == '>' { - current.push(ch); - tokens.push(current.clone()); - current.clear(); - } else { - current.push(ch); - } - } - if !current.is_empty() { - tokens.push(current); - } - - tokens -} - -/// Handles formatting of an opening tag token, including inline text -/// optimization where `<tag>text</tag>` stays on one line. -/// -/// Returns the number of extra tokens consumed (for the caller to skip). -fn format_open_tag( - tokens: &[String], - i: usize, - result: &mut String, - indent_level: &mut usize, - indent_str: &str, -) -> usize { - let token = &tokens[i]; - let is_self_closing = token.ends_with("/>"); - - // Check if the next token is text content (not another tag) - let next_is_text = tokens.get(i + 1).is_some_and(|t| !t.starts_with('<')); - - // Check if it's like <tag>text</tag> (inline text content) - let is_inline_text = next_is_text && tokens.get(i + 2).is_some_and(|t| t.starts_with("</")); - - if is_self_closing { - push_indent(result, *indent_level, indent_str); - result.push_str(token); - result.push('\n'); - 0 - } else if is_inline_text { - // Output <tag>text</tag> on one line - push_indent(result, *indent_level, indent_str); - result.push_str(token); - result.push_str(&tokens[i + 1]); // text - result.push_str(&tokens[i + 2]); // </tag> - result.push('\n'); - 2 // skip text + closing tag - } else { - push_indent(result, *indent_level, indent_str); - result.push_str(token); - if next_is_text { - result.push_str(&tokens[i + 1]); - *indent_level += 1; - 1 // skip the text token - } else { - result.push('\n'); - *indent_level += 1; - 0 - } - } -} - -/// Writes indentation to the output string. -fn push_indent(out: &mut String, level: usize, indent: &str) { - for _ in 0..level { - out.push_str(indent); - } -} - -// --------------------------------------------------------------------------- -// Encoding -// --------------------------------------------------------------------------- - -/// Updates the encoding attribute in an XML declaration, if present. -fn update_encoding_declaration(xml: &str, new_encoding: &str) -> String { - if let Some(decl_end) = xml.find("?>") { - let decl = &xml[..decl_end]; - if let Some(enc_start) = decl.find("encoding=\"") { - let after_enc = &decl[enc_start + 10..]; - if let Some(enc_end) = after_enc.find('"') { - let mut result = String::with_capacity(xml.len()); - result.push_str(&xml[..enc_start + 10]); - result.push_str(new_encoding); - result.push_str(&xml[enc_start + 10 + enc_end..]); - return result; - } - } - } - xml.to_string() -} - -// --------------------------------------------------------------------------- -// Debug tree -// --------------------------------------------------------------------------- - -/// Produces a textual debug representation of the document tree. -/// -/// The format resembles libxml2's `--debug` output: each node is printed -/// with its type and content, indented to show the tree structure. -fn format_debug_tree(doc: &Document) -> String { - let mut output = String::new(); - output.push_str("DOCUMENT\n"); - for child in doc.children(doc.root()) { - format_debug_node(doc, child, 1, &mut output); - } - output -} - -/// Recursively formats a node for debug output. -fn format_debug_node(doc: &Document, id: NodeId, depth: usize, out: &mut String) { - let indent: String = " ".repeat(depth); - - match &doc.node(id).kind { - NodeKind::Element { - name, - prefix, - namespace, - attributes, - } => { - let qname = match prefix { - Some(pfx) => format!("{pfx}:{name}"), - None => name.clone(), - }; - out.push_str(&indent); - out.push_str("ELEMENT "); - out.push_str(&qname); - if let Some(ns) = namespace { - let _ = write!(out, " ns={ns}"); - } - out.push('\n'); - for attr in attributes { - out.push_str(&indent); - out.push_str(" ATTRIBUTE "); - if let Some(pfx) = &attr.prefix { - out.push_str(pfx); - out.push(':'); - } - out.push_str(&attr.name); - out.push('='); - out.push_str(&attr.value); - out.push('\n'); - } - for child in doc.children(id) { - format_debug_node(doc, child, depth + 1, out); - } - } - NodeKind::Text { content } => { - out.push_str(&indent); - out.push_str("TEXT "); - // Show the text content, replacing newlines for readability - let display = content.replace('\n', "\\n"); - out.push_str(&display); - out.push('\n'); - } - NodeKind::CData { content } => { - out.push_str(&indent); - out.push_str("CDATA "); - out.push_str(content); - out.push('\n'); - } - NodeKind::Comment { content } => { - out.push_str(&indent); - out.push_str("COMMENT "); - out.push_str(content); - out.push('\n'); - } - NodeKind::ProcessingInstruction { target, data } => { - out.push_str(&indent); - out.push_str("PI "); - out.push_str(target); - if let Some(d) = data { - out.push(' '); - out.push_str(d); - } - out.push('\n'); - } - NodeKind::EntityRef { name, .. } => { - out.push_str(&indent); - out.push_str("ENTITY_REF "); - out.push_str(name); - out.push('\n'); - } - NodeKind::DocumentType { - name, - system_id, - public_id, - .. - } => { - out.push_str(&indent); - out.push_str("DOCTYPE "); - out.push_str(name); - if let Some(pub_id) = public_id { - let _ = write!(out, " PUBLIC \"{pub_id}\""); - } - if let Some(sys_id) = system_id { - let _ = write!(out, " SYSTEM \"{sys_id}\""); - } - out.push('\n'); - } - NodeKind::Document => { - out.push_str(&indent); - out.push_str("DOCUMENT\n"); - } - } -} - -// --------------------------------------------------------------------------- -// Output writing -// --------------------------------------------------------------------------- - -/// Writes output to stdout or to the file specified by --output. -fn write_output(cli: &Cli, content: &str) { - if let Some(ref output_file) = cli.output { - if let Err(e) = fs::write(output_file, content) { - eprintln!("{output_file}: failed to write: {e}"); - } - } else { - print!("{content}"); - // Flush stdout to ensure output is complete, especially when piped. - let _ = io::stdout().flush(); - } -} - -// --------------------------------------------------------------------------- -// DTD extraction helper -// --------------------------------------------------------------------------- - -/// Extracts the internal DTD subset text from the document, if any. -/// -/// Looks for a `DocumentType` node and attempts to extract a minimal DTD from -/// the document's content model. This is a best-effort approach -- a full -/// implementation would capture the internal subset during parsing. -fn extract_internal_dtd_subset(doc: &Document) -> String { - // Walk the document's top-level children looking for a DocumentType node. - for child in doc.children(doc.root()) { - if matches!(doc.node(child).kind, NodeKind::DocumentType { .. }) { - // We found a DOCTYPE but the current tree representation doesn't - // store the internal subset text. Return empty to indicate that - // the DTD can't be extracted from the tree alone. - return String::new(); - } - } - String::new() -} diff --git a/browser/vendor/xmloxide/src/catalog/mod.rs b/browser/vendor/xmloxide/src/catalog/mod.rs deleted file mode 100644 index dd640b15b..000000000 --- a/browser/vendor/xmloxide/src/catalog/mod.rs +++ /dev/null @@ -1,1196 +0,0 @@ -//! XML Catalogs for URI resolution (OASIS XML Catalogs 1.1). -//! -//! XML Catalogs provide a mechanism to map public identifiers and system -//! identifiers (URIs) to local resources. This enables offline validation, -//! entity resolution, and redirection of external resources to local copies. -//! -//! The implementation follows the [OASIS XML Catalogs 1.1](https://www.oasis-open.org/committees/entity/spec-2001-08-06.html) -//! specification and supports all standard catalog entry types: `public`, -//! `system`, `rewriteSystem`, `rewriteURI`, `uri`, `delegatePublic`, -//! `delegateSystem`, `nextCatalog`, `systemSuffix`, and `uriSuffix`. -//! -//! # Examples -//! -//! ``` -//! use xmloxide::catalog::{Catalog, CatalogEntry}; -//! -//! let mut catalog = Catalog::new(); -//! catalog.add_entry(CatalogEntry::Public { -//! public_id: "-//W3C//DTD XHTML 1.0 Strict//EN".to_string(), -//! uri: "dtd/xhtml1-strict.dtd".to_string(), -//! }); -//! -//! let resolved = catalog.resolve_public("-//W3C//DTD XHTML 1.0 Strict//EN"); -//! assert_eq!(resolved, Some("dtd/xhtml1-strict.dtd".to_string())); -//! ``` - -use std::fmt; - -use crate::tree::{Document, NodeKind}; - -/// The OASIS XML Catalog namespace URI. -const CATALOG_NAMESPACE: &str = "urn:oasis:names:tc:entity:xmlns:xml:catalog"; - -/// An XML Catalog for resolving public/system identifiers to local URIs. -/// -/// A catalog contains an ordered list of entries that are consulted during -/// identifier resolution. Entries are tried in order, with the first match -/// winning (except for rewrite rules, where the longest prefix match wins). -#[derive(Debug, Clone)] -pub struct Catalog { - entries: Vec<CatalogEntry>, -} - -/// A single entry in an XML catalog. -/// -/// Each variant corresponds to an element in the OASIS XML Catalog format. -/// The catalog processor tries entries in document order, with specific -/// matching rules for each type. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CatalogEntry { - /// Maps a public identifier to a URI. - /// - /// Corresponds to the `<public>` element. The `public_id` is normalized - /// (leading/trailing whitespace stripped, internal whitespace collapsed) - /// before matching. - Public { - /// The public identifier to match. - public_id: String, - /// The URI to resolve to. - uri: String, - }, - - /// Maps a system identifier to a URI. - /// - /// Corresponds to the `<system>` element. The `system_id` must match - /// exactly (after URI normalization). - System { - /// The system identifier to match. - system_id: String, - /// The URI to resolve to. - uri: String, - }, - - /// Rewrites the beginning of a system identifier. - /// - /// Corresponds to the `<rewriteSystem>` element. When multiple rewrite - /// rules match, the one with the longest matching prefix wins. - RewriteSystem { - /// The prefix to match against the start of a system identifier. - start: String, - /// The replacement prefix. - rewrite_prefix: String, - }, - - /// Rewrites the beginning of a URI. - /// - /// Corresponds to the `<rewriteURI>` element. When multiple rewrite - /// rules match, the one with the longest matching prefix wins. - RewriteUri { - /// The prefix to match against the start of a URI. - start: String, - /// The replacement prefix. - rewrite_prefix: String, - }, - - /// Maps a URI to another URI. - /// - /// Corresponds to the `<uri>` element. The `name` must match exactly. - Uri { - /// The URI to match. - name: String, - /// The URI to resolve to. - uri: String, - }, - - /// Delegates matching public IDs to another catalog. - /// - /// Corresponds to the `<delegatePublic>` element. When a public - /// identifier starts with the given prefix, resolution is delegated - /// to the specified catalog file. - DelegatePublic { - /// The public identifier prefix to match. - start: String, - /// The URI of the catalog to delegate to. - catalog: String, - }, - - /// Delegates matching system IDs to another catalog. - /// - /// Corresponds to the `<delegateSystem>` element. When a system - /// identifier starts with the given prefix, resolution is delegated - /// to the specified catalog file. - DelegateSystem { - /// The system identifier prefix to match. - start: String, - /// The URI of the catalog to delegate to. - catalog: String, - }, - - /// Adds another catalog to search. - /// - /// Corresponds to the `<nextCatalog>` element. When resolution fails - /// in the current catalog, the next catalog is consulted. - NextCatalog { - /// The URI of the next catalog to search. - catalog: String, - }, - - /// System ID suffix matching. - /// - /// Corresponds to the `<systemSuffix>` element. Matches system - /// identifiers that end with the given suffix. - SystemSuffix { - /// The suffix to match against the end of a system identifier. - suffix: String, - /// The URI to resolve to. - uri: String, - }, - - /// URI suffix matching. - /// - /// Corresponds to the `<uriSuffix>` element. Matches URIs that end - /// with the given suffix. - UriSuffix { - /// The suffix to match against the end of a URI. - suffix: String, - /// The URI to resolve to. - uri: String, - }, -} - -/// An error that can occur during catalog parsing. -/// -/// This error is returned when the catalog XML cannot be parsed or when -/// the catalog structure does not conform to the OASIS XML Catalog format. -#[derive(Debug, Clone)] -pub struct CatalogError { - /// Human-readable description of the error. - pub message: String, -} - -impl fmt::Display for CatalogError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "catalog error: {}", self.message) - } -} - -impl std::error::Error for CatalogError {} - -impl Catalog { - /// Creates an empty catalog with no entries. - /// - /// # Examples - /// - /// ``` - /// use xmloxide::catalog::Catalog; - /// - /// let catalog = Catalog::new(); - /// assert!(catalog.is_empty()); - /// ``` - #[must_use] - pub fn new() -> Self { - Self { - entries: Vec::new(), - } - } - - /// Parses an XML catalog from a string in OASIS XML Catalog format. - /// - /// The input must be a well-formed XML document with a root `<catalog>` - /// element in the `urn:oasis:names:tc:entity:xmlns:xml:catalog` namespace. - /// - /// # Errors - /// - /// Returns `CatalogError` if: - /// - The input is not well-formed XML - /// - The root element is not `<catalog>` in the catalog namespace - /// - Required attributes are missing on catalog entries - /// - /// # Examples - /// - /// ``` - /// use xmloxide::catalog::Catalog; - /// - /// let xml = r#"<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog"> - /// <public publicId="-//Example//EN" uri="example.dtd"/> - /// </catalog>"#; - /// - /// let catalog = Catalog::parse(xml).unwrap(); - /// assert_eq!(catalog.len(), 1); - /// ``` - pub fn parse(xml: &str) -> Result<Self, CatalogError> { - let doc = Document::parse_str(xml).map_err(|e| CatalogError { - message: format!("failed to parse catalog XML: {e}"), - })?; - - let root_element = doc.root_element().ok_or_else(|| CatalogError { - message: "catalog document has no root element".to_string(), - })?; - - // Verify the root element is <catalog> in the catalog namespace. - let root_name = doc.node_name(root_element).unwrap_or(""); - let root_ns = doc.node_namespace(root_element); - - if root_name != "catalog" { - return Err(CatalogError { - message: format!("expected root element 'catalog', found '{root_name}'"), - }); - } - - if root_ns != Some(CATALOG_NAMESPACE) { - return Err(CatalogError { - message: format!("root element must be in namespace '{CATALOG_NAMESPACE}'"), - }); - } - - let mut catalog = Self::new(); - - for child in doc.children(root_element) { - if let NodeKind::Element { ref name, .. } = doc.node(child).kind { - if let Some(entry) = parse_catalog_entry(&doc, child, name)? { - catalog.entries.push(entry); - } - } - } - - Ok(catalog) - } - - /// Adds an entry to the catalog. - /// - /// Entries are tried in insertion order during resolution. - /// - /// # Examples - /// - /// ``` - /// use xmloxide::catalog::{Catalog, CatalogEntry}; - /// - /// let mut catalog = Catalog::new(); - /// catalog.add_entry(CatalogEntry::System { - /// system_id: "http://example.com/schema.xsd".to_string(), - /// uri: "local/schema.xsd".to_string(), - /// }); - /// assert_eq!(catalog.len(), 1); - /// ``` - pub fn add_entry(&mut self, entry: CatalogEntry) { - self.entries.push(entry); - } - - /// Resolves a public identifier to a URI. - /// - /// Searches catalog entries in order for a matching `Public` entry. - /// Public identifiers are compared after normalization (whitespace - /// collapsing). - /// - /// Returns `None` if no matching entry is found. - /// - /// # Examples - /// - /// ``` - /// use xmloxide::catalog::{Catalog, CatalogEntry}; - /// - /// let mut catalog = Catalog::new(); - /// catalog.add_entry(CatalogEntry::Public { - /// public_id: "-//W3C//DTD XHTML 1.0//EN".to_string(), - /// uri: "xhtml1.dtd".to_string(), - /// }); - /// - /// assert_eq!( - /// catalog.resolve_public("-//W3C//DTD XHTML 1.0//EN"), - /// Some("xhtml1.dtd".to_string()) - /// ); - /// assert_eq!(catalog.resolve_public("-//Unknown//EN"), None); - /// ``` - #[must_use] - pub fn resolve_public(&self, public_id: &str) -> Option<String> { - let normalized = normalize_public_id(public_id); - - // Try exact public match first. - for entry in &self.entries { - if let CatalogEntry::Public { - public_id: ref pid, - ref uri, - } = *entry - { - if normalize_public_id(pid) == normalized { - return Some(uri.clone()); - } - } - } - - // Try delegatePublic matches (longest prefix wins). - let mut best_delegate: Option<(&str, usize)> = None; - for entry in &self.entries { - if let CatalogEntry::DelegatePublic { - ref start, - ref catalog, - } = *entry - { - if normalized.starts_with(start.as_str()) - && start.len() > best_delegate.map_or(0, |(_, len)| len) - { - best_delegate = Some((catalog.as_str(), start.len())); - } - } - } - - if let Some((catalog_uri, _)) = best_delegate { - return Some(catalog_uri.to_string()); - } - - None - } - - /// Resolves a system identifier to a URI. - /// - /// The resolution order is: - /// 1. Exact `System` match - /// 2. `RewriteSystem` prefix match (longest prefix wins) - /// 3. `SystemSuffix` suffix match (longest suffix wins) - /// 4. `DelegateSystem` prefix match (longest prefix wins) - /// - /// Returns `None` if no matching entry is found. - /// - /// # Examples - /// - /// ``` - /// use xmloxide::catalog::{Catalog, CatalogEntry}; - /// - /// let mut catalog = Catalog::new(); - /// catalog.add_entry(CatalogEntry::System { - /// system_id: "http://example.com/schema.xsd".to_string(), - /// uri: "local/schema.xsd".to_string(), - /// }); - /// - /// assert_eq!( - /// catalog.resolve_system("http://example.com/schema.xsd"), - /// Some("local/schema.xsd".to_string()) - /// ); - /// ``` - #[must_use] - pub fn resolve_system(&self, system_id: &str) -> Option<String> { - // 1. Try exact system match. - for entry in &self.entries { - if let CatalogEntry::System { - system_id: ref sid, - ref uri, - } = *entry - { - if sid == system_id { - return Some(uri.clone()); - } - } - } - - // 2. Try rewriteSystem (longest prefix wins). - if let Some(result) = self.resolve_rewrite_system(system_id) { - return Some(result); - } - - // 3. Try systemSuffix (longest suffix wins). - if let Some(result) = self.resolve_system_suffix(system_id) { - return Some(result); - } - - // 4. Try delegateSystem (longest prefix wins). - let mut best_delegate: Option<(&str, usize)> = None; - for entry in &self.entries { - if let CatalogEntry::DelegateSystem { - ref start, - ref catalog, - } = *entry - { - if system_id.starts_with(start.as_str()) - && start.len() > best_delegate.map_or(0, |(_, len)| len) - { - best_delegate = Some((catalog.as_str(), start.len())); - } - } - } - - if let Some((catalog_uri, _)) = best_delegate { - return Some(catalog_uri.to_string()); - } - - None - } - - /// Resolves a URI reference. - /// - /// The resolution order is: - /// 1. Exact `Uri` match - /// 2. `RewriteUri` prefix match (longest prefix wins) - /// 3. `UriSuffix` suffix match (longest suffix wins) - /// - /// Returns `None` if no matching entry is found. - /// - /// # Examples - /// - /// ``` - /// use xmloxide::catalog::{Catalog, CatalogEntry}; - /// - /// let mut catalog = Catalog::new(); - /// catalog.add_entry(CatalogEntry::Uri { - /// name: "http://example.com/schema.xsd".to_string(), - /// uri: "local/schema.xsd".to_string(), - /// }); - /// - /// assert_eq!( - /// catalog.resolve_uri("http://example.com/schema.xsd"), - /// Some("local/schema.xsd".to_string()) - /// ); - /// ``` - #[must_use] - pub fn resolve_uri(&self, uri: &str) -> Option<String> { - // 1. Try exact URI match. - for entry in &self.entries { - if let CatalogEntry::Uri { - ref name, - uri: ref target, - } = *entry - { - if name == uri { - return Some(target.clone()); - } - } - } - - // 2. Try rewriteURI (longest prefix wins). - if let Some(result) = self.resolve_rewrite_uri(uri) { - return Some(result); - } - - // 3. Try uriSuffix (longest suffix wins). - let mut best_suffix: Option<(&str, usize)> = None; - for entry in &self.entries { - if let CatalogEntry::UriSuffix { - ref suffix, - uri: ref target, - } = *entry - { - if uri.ends_with(suffix.as_str()) - && suffix.len() > best_suffix.map_or(0, |(_, len)| len) - { - best_suffix = Some((target.as_str(), suffix.len())); - } - } - } - - if let Some((target, _)) = best_suffix { - return Some(target.to_string()); - } - - None - } - - /// Resolves either a public or system identifier, trying system first. - /// - /// This is the primary resolution method that follows the OASIS catalog - /// resolution algorithm: system identifiers take precedence over public - /// identifiers because they are more specific. - /// - /// # Examples - /// - /// ``` - /// use xmloxide::catalog::{Catalog, CatalogEntry}; - /// - /// let mut catalog = Catalog::new(); - /// catalog.add_entry(CatalogEntry::Public { - /// public_id: "-//Example//EN".to_string(), - /// uri: "public.dtd".to_string(), - /// }); - /// catalog.add_entry(CatalogEntry::System { - /// system_id: "http://example.com/doc.dtd".to_string(), - /// uri: "system.dtd".to_string(), - /// }); - /// - /// // System takes precedence. - /// assert_eq!( - /// catalog.resolve(Some("-//Example//EN"), Some("http://example.com/doc.dtd")), - /// Some("system.dtd".to_string()) - /// ); - /// - /// // Falls back to public when system is not provided. - /// assert_eq!( - /// catalog.resolve(Some("-//Example//EN"), None), - /// Some("public.dtd".to_string()) - /// ); - /// ``` - #[must_use] - pub fn resolve(&self, public_id: Option<&str>, system_id: Option<&str>) -> Option<String> { - // Try system identifier first (more specific). - if let Some(sid) = system_id { - if let Some(resolved) = self.resolve_system(sid) { - return Some(resolved); - } - } - - // Fall back to public identifier. - if let Some(pid) = public_id { - if let Some(resolved) = self.resolve_public(pid) { - return Some(resolved); - } - } - - None - } - - /// Merges another catalog's entries into this one. - /// - /// All entries from `other` are appended to this catalog's entry list, - /// preserving order. The other catalog's entries will be tried after - /// the existing entries during resolution. - /// - /// # Examples - /// - /// ``` - /// use xmloxide::catalog::{Catalog, CatalogEntry}; - /// - /// let mut catalog1 = Catalog::new(); - /// catalog1.add_entry(CatalogEntry::Public { - /// public_id: "-//A//EN".to_string(), - /// uri: "a.dtd".to_string(), - /// }); - /// - /// let mut catalog2 = Catalog::new(); - /// catalog2.add_entry(CatalogEntry::Public { - /// public_id: "-//B//EN".to_string(), - /// uri: "b.dtd".to_string(), - /// }); - /// - /// catalog1.merge(&catalog2); - /// assert_eq!(catalog1.len(), 2); - /// ``` - pub fn merge(&mut self, other: &Catalog) { - self.entries.extend(other.entries.iter().cloned()); - } - - /// Returns the number of entries in the catalog. - /// - /// # Examples - /// - /// ``` - /// use xmloxide::catalog::Catalog; - /// - /// let catalog = Catalog::new(); - /// assert_eq!(catalog.len(), 0); - /// ``` - #[must_use] - pub fn len(&self) -> usize { - self.entries.len() - } - - /// Returns `true` if the catalog has no entries. - /// - /// # Examples - /// - /// ``` - /// use xmloxide::catalog::Catalog; - /// - /// let catalog = Catalog::new(); - /// assert!(catalog.is_empty()); - /// ``` - #[must_use] - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Returns an iterator over the catalog entries. - pub fn entries(&self) -> impl Iterator<Item = &CatalogEntry> { - self.entries.iter() - } - - // --- Private resolution helpers --- - - /// Finds the best `RewriteSystem` match for the given system ID. - /// - /// Among all `RewriteSystem` entries whose `start` is a prefix of - /// `system_id`, the one with the longest `start` wins. - fn resolve_rewrite_system(&self, system_id: &str) -> Option<String> { - let mut best: Option<(&str, &str, usize)> = None; - - for entry in &self.entries { - if let CatalogEntry::RewriteSystem { - ref start, - ref rewrite_prefix, - } = *entry - { - if system_id.starts_with(start.as_str()) - && start.len() > best.map_or(0, |(_, _, len)| len) - { - best = Some((start.as_str(), rewrite_prefix.as_str(), start.len())); - } - } - } - - best.map(|(start, rewrite_prefix, _)| { - format!("{rewrite_prefix}{}", &system_id[start.len()..]) - }) - } - - /// Finds the best `SystemSuffix` match for the given system ID. - /// - /// Among all `SystemSuffix` entries whose `suffix` matches the end of - /// `system_id`, the one with the longest `suffix` wins. - fn resolve_system_suffix(&self, system_id: &str) -> Option<String> { - let mut best: Option<(&str, usize)> = None; - - for entry in &self.entries { - if let CatalogEntry::SystemSuffix { - ref suffix, - ref uri, - } = *entry - { - if system_id.ends_with(suffix.as_str()) - && suffix.len() > best.map_or(0, |(_, len)| len) - { - best = Some((uri.as_str(), suffix.len())); - } - } - } - - best.map(|(uri, _)| uri.to_string()) - } - - /// Finds the best `RewriteUri` match for the given URI. - fn resolve_rewrite_uri(&self, uri: &str) -> Option<String> { - let mut best: Option<(&str, &str, usize)> = None; - - for entry in &self.entries { - if let CatalogEntry::RewriteUri { - ref start, - ref rewrite_prefix, - } = *entry - { - if uri.starts_with(start.as_str()) - && start.len() > best.map_or(0, |(_, _, len)| len) - { - best = Some((start.as_str(), rewrite_prefix.as_str(), start.len())); - } - } - } - - best.map(|(start, rewrite_prefix, _)| format!("{rewrite_prefix}{}", &uri[start.len()..])) - } -} - -impl Default for Catalog { - fn default() -> Self { - Self::new() - } -} - -/// Normalizes a public identifier by collapsing whitespace. -/// -/// Per the OASIS catalog specification, public identifiers are compared -/// after stripping leading/trailing whitespace and collapsing all internal -/// whitespace sequences to a single space. -fn normalize_public_id(public_id: &str) -> String { - public_id.split_whitespace().collect::<Vec<_>>().join(" ") -} - -/// Parses a single catalog entry element into a `CatalogEntry`. -/// -/// Returns `Ok(None)` for unrecognized elements (which are silently ignored -/// per the catalog specification). Returns `Err` if a recognized element -/// is missing required attributes. -fn parse_catalog_entry( - doc: &Document, - node: crate::NodeId, - name: &str, -) -> Result<Option<CatalogEntry>, CatalogError> { - match name { - "public" => { - let public_id = require_attr(doc, node, "publicId", "public")?; - let uri = require_attr(doc, node, "uri", "public")?; - Ok(Some(CatalogEntry::Public { public_id, uri })) - } - "system" => { - let system_id = require_attr(doc, node, "systemId", "system")?; - let uri = require_attr(doc, node, "uri", "system")?; - Ok(Some(CatalogEntry::System { system_id, uri })) - } - "rewriteSystem" => { - let start = require_attr(doc, node, "systemIdStartString", "rewriteSystem")?; - let rewrite_prefix = require_attr(doc, node, "rewritePrefix", "rewriteSystem")?; - Ok(Some(CatalogEntry::RewriteSystem { - start, - rewrite_prefix, - })) - } - "rewriteURI" => { - let start = require_attr(doc, node, "uriStartString", "rewriteURI")?; - let rewrite_prefix = require_attr(doc, node, "rewritePrefix", "rewriteURI")?; - Ok(Some(CatalogEntry::RewriteUri { - start, - rewrite_prefix, - })) - } - "uri" => { - let name = require_attr(doc, node, "name", "uri")?; - let uri = require_attr(doc, node, "uri", "uri")?; - Ok(Some(CatalogEntry::Uri { name, uri })) - } - "delegatePublic" => { - let start = require_attr(doc, node, "publicIdStartString", "delegatePublic")?; - let catalog = require_attr(doc, node, "catalog", "delegatePublic")?; - Ok(Some(CatalogEntry::DelegatePublic { start, catalog })) - } - "delegateSystem" => { - let start = require_attr(doc, node, "systemIdStartString", "delegateSystem")?; - let catalog = require_attr(doc, node, "catalog", "delegateSystem")?; - Ok(Some(CatalogEntry::DelegateSystem { start, catalog })) - } - "nextCatalog" => { - let catalog = require_attr(doc, node, "catalog", "nextCatalog")?; - Ok(Some(CatalogEntry::NextCatalog { catalog })) - } - "systemSuffix" => { - let suffix = require_attr(doc, node, "systemIdSuffix", "systemSuffix")?; - let uri = require_attr(doc, node, "uri", "systemSuffix")?; - Ok(Some(CatalogEntry::SystemSuffix { suffix, uri })) - } - "uriSuffix" => { - let suffix = require_attr(doc, node, "uriSuffix", "uriSuffix")?; - let uri = require_attr(doc, node, "uri", "uriSuffix")?; - Ok(Some(CatalogEntry::UriSuffix { suffix, uri })) - } - // Unrecognized elements in the catalog namespace are silently ignored, - // following the extensibility rules in the OASIS specification. - _ => Ok(None), - } -} - -/// Extracts a required attribute from an element, returning a `CatalogError` -/// if the attribute is missing. -fn require_attr( - doc: &Document, - node: crate::NodeId, - attr_name: &str, - element_name: &str, -) -> Result<String, CatalogError> { - doc.attribute(node, attr_name) - .map(ToString::to_string) - .ok_or_else(|| CatalogError { - message: format!( - "missing required attribute '{attr_name}' on <{element_name}> element" - ), - }) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - - fn catalog_xml(body: &str) -> String { - format!(r#"<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">{body}</catalog>"#) - } - - #[test] - fn test_parse_simple_catalog_with_public_entry() { - let xml = catalog_xml( - r#"<public publicId="-//W3C//DTD XHTML 1.0 Strict//EN" uri="dtd/xhtml1-strict.dtd"/>"#, - ); - let catalog = Catalog::parse(&xml).unwrap(); - assert_eq!(catalog.len(), 1); - assert_eq!( - catalog.entries().next(), - Some(&CatalogEntry::Public { - public_id: "-//W3C//DTD XHTML 1.0 Strict//EN".to_string(), - uri: "dtd/xhtml1-strict.dtd".to_string(), - }) - ); - } - - #[test] - fn test_parse_catalog_with_system_entry() { - let xml = catalog_xml( - r#"<system systemId="http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd" uri="dtd/xhtml1-strict.dtd"/>"#, - ); - let catalog = Catalog::parse(&xml).unwrap(); - assert_eq!(catalog.len(), 1); - assert_eq!( - catalog.entries().next(), - Some(&CatalogEntry::System { - system_id: "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd".to_string(), - uri: "dtd/xhtml1-strict.dtd".to_string(), - }) - ); - } - - #[test] - fn test_parse_catalog_with_rewrite_entries() { - let xml = catalog_xml( - r#"<rewriteSystem systemIdStartString="http://www.w3.org/TR/" rewritePrefix="file:///usr/share/xml/w3c/"/> - <rewriteURI uriStartString="http://example.com/" rewritePrefix="file:///local/"/>"#, - ); - let catalog = Catalog::parse(&xml).unwrap(); - assert_eq!(catalog.len(), 2); - } - - #[test] - fn test_resolve_public_identifier() { - let mut catalog = Catalog::new(); - catalog.add_entry(CatalogEntry::Public { - public_id: "-//W3C//DTD XHTML 1.0 Strict//EN".to_string(), - uri: "dtd/xhtml1-strict.dtd".to_string(), - }); - - assert_eq!( - catalog.resolve_public("-//W3C//DTD XHTML 1.0 Strict//EN"), - Some("dtd/xhtml1-strict.dtd".to_string()) - ); - } - - #[test] - fn test_resolve_system_identifier() { - let mut catalog = Catalog::new(); - catalog.add_entry(CatalogEntry::System { - system_id: "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd".to_string(), - uri: "dtd/xhtml1-strict.dtd".to_string(), - }); - - assert_eq!( - catalog.resolve_system("http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"), - Some("dtd/xhtml1-strict.dtd".to_string()) - ); - } - - #[test] - fn test_resolve_system_with_rewrite_prefix() { - let mut catalog = Catalog::new(); - catalog.add_entry(CatalogEntry::RewriteSystem { - start: "http://www.w3.org/TR/".to_string(), - rewrite_prefix: "file:///usr/share/xml/w3c/".to_string(), - }); - - assert_eq!( - catalog.resolve_system("http://www.w3.org/TR/xhtml1/DTD/strict.dtd"), - Some("file:///usr/share/xml/w3c/xhtml1/DTD/strict.dtd".to_string()) - ); - } - - #[test] - fn test_resolve_uri() { - let mut catalog = Catalog::new(); - catalog.add_entry(CatalogEntry::Uri { - name: "http://example.com/schema.xsd".to_string(), - uri: "local/schema.xsd".to_string(), - }); - - assert_eq!( - catalog.resolve_uri("http://example.com/schema.xsd"), - Some("local/schema.xsd".to_string()) - ); - } - - #[test] - fn test_resolve_with_suffix_matching() { - let mut catalog = Catalog::new(); - catalog.add_entry(CatalogEntry::SystemSuffix { - suffix: "strict.dtd".to_string(), - uri: "local/strict.dtd".to_string(), - }); - - assert_eq!( - catalog.resolve_system("http://example.com/path/to/strict.dtd"), - Some("local/strict.dtd".to_string()) - ); - } - - #[test] - fn test_no_match_returns_none() { - let catalog = Catalog::new(); - assert_eq!(catalog.resolve_public("-//Unknown//EN"), None); - assert_eq!( - catalog.resolve_system("http://unknown.example.com/foo"), - None - ); - assert_eq!(catalog.resolve_uri("http://unknown.example.com/bar"), None); - assert_eq!(catalog.resolve(None, None), None); - } - - #[test] - fn test_merge_two_catalogs() { - let mut catalog1 = Catalog::new(); - catalog1.add_entry(CatalogEntry::Public { - public_id: "-//A//EN".to_string(), - uri: "a.dtd".to_string(), - }); - - let mut catalog2 = Catalog::new(); - catalog2.add_entry(CatalogEntry::Public { - public_id: "-//B//EN".to_string(), - uri: "b.dtd".to_string(), - }); - - catalog1.merge(&catalog2); - assert_eq!(catalog1.len(), 2); - assert_eq!( - catalog1.resolve_public("-//A//EN"), - Some("a.dtd".to_string()) - ); - assert_eq!( - catalog1.resolve_public("-//B//EN"), - Some("b.dtd".to_string()) - ); - } - - #[test] - fn test_empty_catalog() { - let catalog = Catalog::new(); - assert!(catalog.is_empty()); - assert_eq!(catalog.len(), 0); - } - - #[test] - fn test_add_entry_programmatically() { - let mut catalog = Catalog::new(); - assert!(catalog.is_empty()); - - catalog.add_entry(CatalogEntry::System { - system_id: "http://example.com/test.dtd".to_string(), - uri: "test.dtd".to_string(), - }); - - assert!(!catalog.is_empty()); - assert_eq!(catalog.len(), 1); - assert_eq!( - catalog.resolve_system("http://example.com/test.dtd"), - Some("test.dtd".to_string()) - ); - } - - #[test] - fn test_catalog_len_and_is_empty() { - let mut catalog = Catalog::new(); - assert_eq!(catalog.len(), 0); - assert!(catalog.is_empty()); - - catalog.add_entry(CatalogEntry::NextCatalog { - catalog: "other.xml".to_string(), - }); - assert_eq!(catalog.len(), 1); - assert!(!catalog.is_empty()); - - catalog.add_entry(CatalogEntry::NextCatalog { - catalog: "another.xml".to_string(), - }); - assert_eq!(catalog.len(), 2); - } - - #[test] - fn test_complex_catalog_with_multiple_entry_types() { - let xml = catalog_xml( - r#"<public publicId="-//W3C//DTD XHTML 1.0 Strict//EN" uri="dtd/xhtml1-strict.dtd"/> - <system systemId="http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd" uri="dtd/xhtml1-strict.dtd"/> - <rewriteSystem systemIdStartString="http://www.w3.org/TR/" rewritePrefix="file:///local/w3c/"/> - <uri name="http://example.com/schema.xsd" uri="local/schema.xsd"/> - <nextCatalog catalog="other-catalog.xml"/>"#, - ); - - let catalog = Catalog::parse(&xml).unwrap(); - assert_eq!(catalog.len(), 5); - - assert!(catalog - .resolve_public("-//W3C//DTD XHTML 1.0 Strict//EN") - .is_some()); - assert!(catalog - .resolve_system("http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd") - .is_some()); - assert!(catalog - .resolve_system("http://www.w3.org/TR/other/doc.xml") - .is_some()); - assert!(catalog - .resolve_uri("http://example.com/schema.xsd") - .is_some()); - } - - #[test] - fn test_resolve_prefers_system_over_public() { - let mut catalog = Catalog::new(); - catalog.add_entry(CatalogEntry::Public { - public_id: "-//Example//EN".to_string(), - uri: "public-result.dtd".to_string(), - }); - catalog.add_entry(CatalogEntry::System { - system_id: "http://example.com/doc.dtd".to_string(), - uri: "system-result.dtd".to_string(), - }); - - // When both are provided, system wins. - assert_eq!( - catalog.resolve(Some("-//Example//EN"), Some("http://example.com/doc.dtd")), - Some("system-result.dtd".to_string()) - ); - - // When only public is provided, public wins. - assert_eq!( - catalog.resolve(Some("-//Example//EN"), None), - Some("public-result.dtd".to_string()) - ); - - // When only system is provided, system wins. - assert_eq!( - catalog.resolve(None, Some("http://example.com/doc.dtd")), - Some("system-result.dtd".to_string()) - ); - } - - #[test] - fn test_rewrite_system_longest_prefix_wins() { - let mut catalog = Catalog::new(); - catalog.add_entry(CatalogEntry::RewriteSystem { - start: "http://www.w3.org/".to_string(), - rewrite_prefix: "file:///short/".to_string(), - }); - catalog.add_entry(CatalogEntry::RewriteSystem { - start: "http://www.w3.org/TR/xhtml1/".to_string(), - rewrite_prefix: "file:///long/".to_string(), - }); - - // The longer prefix match should win. - assert_eq!( - catalog.resolve_system("http://www.w3.org/TR/xhtml1/DTD/strict.dtd"), - Some("file:///long/DTD/strict.dtd".to_string()) - ); - - // A URL that only matches the shorter prefix. - assert_eq!( - catalog.resolve_system("http://www.w3.org/other/file.xml"), - Some("file:///short/other/file.xml".to_string()) - ); - } - - #[test] - fn test_catalog_error_display() { - let err = CatalogError { - message: "missing required attribute".to_string(), - }; - assert_eq!(err.to_string(), "catalog error: missing required attribute"); - } - - #[test] - fn test_parse_invalid_xml_returns_error() { - let result = Catalog::parse("not valid xml <><>"); - assert!(result.is_err()); - } - - #[test] - fn test_parse_wrong_root_element() { - let xml = r#"<notcatalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog"/>"#; - let result = Catalog::parse(xml); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.message.contains("expected root element 'catalog'")); - } - - #[test] - fn test_parse_missing_namespace() { - let xml = r#"<catalog><public publicId="test" uri="test.dtd"/></catalog>"#; - let result = Catalog::parse(xml); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.message.contains("namespace")); - } - - #[test] - fn test_parse_missing_required_attribute() { - let xml = catalog_xml(r#"<public publicId="-//Test//EN"/>"#); - let result = Catalog::parse(&xml); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.message.contains("uri")); - } - - #[test] - fn test_public_id_whitespace_normalization() { - let mut catalog = Catalog::new(); - catalog.add_entry(CatalogEntry::Public { - public_id: "-//W3C//DTD XHTML 1.0//EN".to_string(), - uri: "xhtml.dtd".to_string(), - }); - - // Extra whitespace in the query should still match. - assert_eq!( - catalog.resolve_public("-//W3C//DTD XHTML 1.0//EN"), - Some("xhtml.dtd".to_string()) - ); - } - - #[test] - fn test_uri_suffix_matching() { - let mut catalog = Catalog::new(); - catalog.add_entry(CatalogEntry::UriSuffix { - suffix: "schema.xsd".to_string(), - uri: "local/schema.xsd".to_string(), - }); - - assert_eq!( - catalog.resolve_uri("http://example.com/path/to/schema.xsd"), - Some("local/schema.xsd".to_string()) - ); - assert_eq!(catalog.resolve_uri("http://example.com/other.xsd"), None); - } - - #[test] - fn test_rewrite_uri() { - let mut catalog = Catalog::new(); - catalog.add_entry(CatalogEntry::RewriteUri { - start: "http://example.com/schemas/".to_string(), - rewrite_prefix: "file:///local/schemas/".to_string(), - }); - - assert_eq!( - catalog.resolve_uri("http://example.com/schemas/types/main.xsd"), - Some("file:///local/schemas/types/main.xsd".to_string()) - ); - } - - #[test] - fn test_delegate_public() { - let mut catalog = Catalog::new(); - catalog.add_entry(CatalogEntry::DelegatePublic { - start: "-//W3C//".to_string(), - catalog: "w3c-catalog.xml".to_string(), - }); - - // DelegatePublic returns the catalog URI for matching public IDs. - assert_eq!( - catalog.resolve_public("-//W3C//DTD XHTML 1.0//EN"), - Some("w3c-catalog.xml".to_string()) - ); - assert_eq!(catalog.resolve_public("-//OASIS//DTD DocBook//EN"), None); - } - - #[test] - fn test_delegate_system() { - let mut catalog = Catalog::new(); - catalog.add_entry(CatalogEntry::DelegateSystem { - start: "http://www.w3.org/".to_string(), - catalog: "w3c-catalog.xml".to_string(), - }); - - assert_eq!( - catalog.resolve_system("http://www.w3.org/TR/xhtml1/DTD/strict.dtd"), - Some("w3c-catalog.xml".to_string()) - ); - assert_eq!(catalog.resolve_system("http://example.com/other.dtd"), None); - } - - #[test] - fn test_default_trait() { - let catalog = Catalog::default(); - assert!(catalog.is_empty()); - } - - #[test] - fn test_catalog_error_is_error_trait() { - let err = CatalogError { - message: "test error".to_string(), - }; - let _: &dyn std::error::Error = &err; - } -} diff --git a/browser/vendor/xmloxide/src/css/eval.rs b/browser/vendor/xmloxide/src/css/eval.rs deleted file mode 100644 index 462c42b4c..000000000 --- a/browser/vendor/xmloxide/src/css/eval.rs +++ /dev/null @@ -1,987 +0,0 @@ -//! CSS selector evaluation against a [`Document`] tree. - -use crate::tree::{Document, NodeId, NodeKind}; - -use super::types::{ - AttrOp, AttrSelector, Combinator, CompoundSelector, NthExpr, PseudoClass, Selector, - SelectorGroup, -}; - -/// Evaluate a parsed selector group against the document, starting from `scope`. -/// -/// Returns all descendant nodes of `scope` that match any selector in the group. -pub fn select(doc: &Document, scope: NodeId, group: &SelectorGroup) -> Vec<NodeId> { - // Fast path: if every selector in the group is a simple `#id` selector, - // use element_by_id for O(1) lookup instead of walking the tree. - if let Some(results) = try_fast_id_select(doc, scope, group) { - return results; - } - - let mut results = Vec::new(); - collect_descendants(doc, scope, group, &mut results); - results -} - -/// Attempts to use the fast `id_map` for pure `#id` selectors. -/// Returns `None` if any selector is not a simple ID selector. -fn try_fast_id_select(doc: &Document, scope: NodeId, group: &SelectorGroup) -> Option<Vec<NodeId>> { - let mut results = Vec::new(); - for sel in &group.selectors { - // Must be a single compound with only an ID - if sel.compounds.len() != 1 { - return None; - } - let compound = &sel.compounds[0].compound; - let id = compound.id.as_ref()?; - if compound.tag.is_some() - || !compound.classes.is_empty() - || !compound.attrs.is_empty() - || !compound.pseudos.is_empty() - { - return None; - } - - // Look up via id_map - if let Some(node) = doc.element_by_id(id) { - // Verify the node is a descendant of scope - if is_descendant_of(doc, node, scope) && !results.contains(&node) { - results.push(node); - } - } - } - Some(results) -} - -/// Returns true if `node` is a descendant of `ancestor`. -fn is_descendant_of(doc: &Document, node: NodeId, ancestor: NodeId) -> bool { - let mut current = doc.parent(node); - while let Some(id) = current { - if id == ancestor { - return true; - } - current = doc.parent(id); - } - false -} - -/// Recursively collect matching descendants. -fn collect_descendants( - doc: &Document, - node: NodeId, - group: &SelectorGroup, - results: &mut Vec<NodeId>, -) { - for child in doc.children(node) { - if matches!(doc.node(child).kind, NodeKind::Element { .. }) { - if group - .selectors - .iter() - .any(|sel| matches_selector(doc, child, sel)) - { - results.push(child); - } - collect_descendants(doc, child, group, results); - } - } -} - -/// Check if a node matches a complete selector (chain of compounds with combinators). -fn matches_selector(doc: &Document, node: NodeId, selector: &Selector) -> bool { - // Walk the compound chain backwards from the rightmost (subject) compound - let compounds = &selector.compounds; - if compounds.is_empty() { - return false; - } - - // The last compound must match the node itself - let last = compounds.len() - 1; - if !matches_compound(doc, node, &compounds[last].compound) { - return false; - } - - // Walk backwards through the chain - let mut current = node; - for i in (0..last).rev() { - let entry = &compounds[i]; - let next_combinator = compounds[i + 1].combinator; - match next_combinator { - Combinator::None => {} - Combinator::Descendant => { - // Find an ancestor that matches - let mut found = false; - let mut ancestor = doc.parent(current); - while let Some(anc) = ancestor { - if matches!(doc.node(anc).kind, NodeKind::Element { .. }) - && matches_compound(doc, anc, &entry.compound) - { - current = anc; - found = true; - break; - } - ancestor = doc.parent(anc); - } - if !found { - return false; - } - } - Combinator::Child => { - // Parent must match - if let Some(parent) = doc.parent(current) { - if matches!(doc.node(parent).kind, NodeKind::Element { .. }) - && matches_compound(doc, parent, &entry.compound) - { - current = parent; - } else { - return false; - } - } else { - return false; - } - } - Combinator::NextSibling => { - // Previous sibling element must match - if let Some(prev) = prev_element_sibling(doc, current) { - if matches_compound(doc, prev, &entry.compound) { - current = prev; - } else { - return false; - } - } else { - return false; - } - } - Combinator::SubsequentSibling => { - // Any preceding sibling element must match - let mut found = false; - let mut prev = prev_element_sibling(doc, current); - while let Some(p) = prev { - if matches_compound(doc, p, &entry.compound) { - current = p; - found = true; - break; - } - prev = prev_element_sibling(doc, p); - } - if !found { - return false; - } - } - } - } - - true -} - -/// Check if a node matches a compound selector (all simple selectors must match). -fn matches_compound(doc: &Document, node: NodeId, compound: &CompoundSelector) -> bool { - // Tag name - if let Some(ref tag) = compound.tag { - let name = doc.node_name(node).unwrap_or(""); - if !name.eq_ignore_ascii_case(tag) { - return false; - } - } - - // ID — use element_by_id for O(1) lookup when the id_map is populated, - // falling back to attribute scan when it's not. - if let Some(ref id) = compound.id { - if let Some(target) = doc.element_by_id(id) { - if target != node { - return false; - } - } else { - // id_map doesn't have this ID — either the element doesn't exist - // or the id_map wasn't populated. Fall back to attribute scan. - let node_id_attr = doc.attribute(node, "id").unwrap_or(""); - if node_id_attr != id { - return false; - } - } - } - - // Classes - for class in &compound.classes { - let class_attr = doc.attribute(node, "class").unwrap_or(""); - if !class_attr.split_ascii_whitespace().any(|c| c == class) { - return false; - } - } - - // Attribute selectors - for attr in &compound.attrs { - if !matches_attr(doc, node, attr) { - return false; - } - } - - // Pseudo-classes - for pseudo in &compound.pseudos { - if !matches_pseudo(doc, node, pseudo) { - return false; - } - } - - true -} - -/// Check if a node matches an attribute selector. -fn matches_attr(doc: &Document, node: NodeId, sel: &AttrSelector) -> bool { - let Some(value) = doc.attribute(node, &sel.name) else { - return false; - }; - - let Some(matcher) = &sel.matcher else { - return true; // existence check only - }; - - let (val, expected) = if matcher.case_insensitive { - ( - value.to_ascii_lowercase(), - matcher.value.to_ascii_lowercase(), - ) - } else { - (value.to_string(), matcher.value.clone()) - }; - - match matcher.op { - AttrOp::Exact => val == expected, - AttrOp::Word => val.split_ascii_whitespace().any(|w| w == expected), - AttrOp::DashPrefix => val == expected || val.starts_with(&format!("{expected}-")), - AttrOp::Prefix => val.starts_with(&expected), - AttrOp::Suffix => val.ends_with(&expected), - AttrOp::Substring => val.contains(&expected), - } -} - -/// Check if a node matches a pseudo-class. -fn matches_pseudo(doc: &Document, node: NodeId, pseudo: &PseudoClass) -> bool { - match pseudo { - PseudoClass::FirstChild => { - // Node is the first element child of its parent - doc.parent(node) - .and_then(|p| first_element_child(doc, p)) - .is_some_and(|first| first == node) - } - PseudoClass::LastChild => doc - .parent(node) - .and_then(|p| last_element_child(doc, p)) - .is_some_and(|last| last == node), - PseudoClass::OnlyChild => { - if let Some(parent) = doc.parent(node) { - let element_children: Vec<_> = doc - .children(parent) - .filter(|&c| matches!(doc.node(c).kind, NodeKind::Element { .. })) - .collect(); - element_children.len() == 1 && element_children[0] == node - } else { - false - } - } - PseudoClass::Empty => { - // No child elements or text nodes - !doc.children(node).any(|c| { - matches!( - doc.node(c).kind, - NodeKind::Element { .. } | NodeKind::Text { .. } | NodeKind::CData { .. } - ) - }) - } - PseudoClass::Not(inner) => !matches_compound(doc, node, inner), - PseudoClass::NthChild(expr) => nth_child_matches(doc, node, *expr, false), - PseudoClass::NthLastChild(expr) => nth_child_matches(doc, node, *expr, true), - } -} - -/// Check if a node's position among sibling elements matches an `An+B` expression. -fn nth_child_matches(doc: &Document, node: NodeId, expr: NthExpr, from_end: bool) -> bool { - let Some(parent) = doc.parent(node) else { - return false; - }; - - let element_children: Vec<_> = doc - .children(parent) - .filter(|&c| matches!(doc.node(c).kind, NodeKind::Element { .. })) - .collect(); - - #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] - let pos = if from_end { - element_children - .iter() - .rev() - .position(|&c| c == node) - .map(|p| p as i32 + 1) - } else { - element_children - .iter() - .position(|&c| c == node) - .map(|p| p as i32 + 1) - }; - - pos.is_some_and(|p| expr.matches(p)) -} - -/// Find the previous element sibling of a node. -fn prev_element_sibling(doc: &Document, node: NodeId) -> Option<NodeId> { - let mut prev = doc.prev_sibling(node); - while let Some(p) = prev { - if matches!(doc.node(p).kind, NodeKind::Element { .. }) { - return Some(p); - } - prev = doc.prev_sibling(p); - } - None -} - -/// Find the first element child. -fn first_element_child(doc: &Document, parent: NodeId) -> Option<NodeId> { - doc.children(parent) - .find(|&c| matches!(doc.node(c).kind, NodeKind::Element { .. })) -} - -/// Find the last element child. -fn last_element_child(doc: &Document, parent: NodeId) -> Option<NodeId> { - doc.children(parent) - .filter(|&c| matches!(doc.node(c).kind, NodeKind::Element { .. })) - .last() -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - use crate::css::parser::parse_selector; - use crate::tree::Document; - - /// Helper: parse a selector string and evaluate it against the document. - fn eval(doc: &Document, scope: NodeId, css: &str) -> Vec<NodeId> { - let group = parse_selector(css).unwrap(); - select(doc, scope, &group) - } - - /// Shared test document covering common structures. - fn test_doc() -> Document { - Document::parse_str( - r#"<root> - <div id="main" class="container wide"> - <h1>Title</h1> - <p class="intro">Hello</p> - <p class="body">World</p> - <ul> - <li class="active">One</li> - <li>Two</li> - <li class="last">Three</li> - </ul> - <a href="https://example.com" data-type="external">Link</a> - <img src="photo.png"/> - <span lang="en-US">English</span> - <span lang="en">Plain English</span> - <span lang="fr">French</span> - <div class="empty-div"/> - </div> - <div id="sidebar" class="sidebar"> - <p class="intro">Side</p> - </div> - </root>"#, - ) - .unwrap() - } - - // --------------------------------------------------------------- - // 1. Basic element matching by tag name - // --------------------------------------------------------------- - - #[test] - fn test_tag_name_match() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "p"); - assert_eq!(result.len(), 3); // 2 in main + 1 in sidebar - for &node in &result { - assert_eq!(doc.node_name(node), Some("p")); - } - } - - #[test] - fn test_tag_name_case_insensitive() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // CSS tag matching should be case-insensitive - let result = eval(&doc, root, "P"); - assert_eq!(result.len(), 3); - } - - #[test] - fn test_tag_name_no_match() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "table"); - assert!(result.is_empty()); - } - - #[test] - fn test_tag_name_h1() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "h1"); - assert_eq!(result.len(), 1); - assert_eq!(doc.text_content(result[0]), "Title"); - } - - // --------------------------------------------------------------- - // 2. Class matching - // --------------------------------------------------------------- - - #[test] - fn test_class_single() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, ".intro"); - assert_eq!(result.len(), 2); // main p.intro + sidebar p.intro - } - - #[test] - fn test_class_multiple_on_element() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // The main div has class="container wide" — match on either individually - let result_container = eval(&doc, root, ".container"); - assert_eq!(result_container.len(), 1); - let result_wide = eval(&doc, root, ".wide"); - assert_eq!(result_wide.len(), 1); - assert_eq!(result_container[0], result_wide[0]); - } - - #[test] - fn test_class_compound_both_required() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // Require both classes on the same element - let result = eval(&doc, root, ".container.wide"); - assert_eq!(result.len(), 1); - assert_eq!(doc.node_name(result[0]), Some("div")); - } - - #[test] - fn test_class_no_match() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, ".nonexistent"); - assert!(result.is_empty()); - } - - #[test] - fn test_class_with_tag() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "p.intro"); - assert_eq!(result.len(), 2); - } - - // --------------------------------------------------------------- - // 3. ID matching - // --------------------------------------------------------------- - - #[test] - fn test_id_match() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "#main"); - assert_eq!(result.len(), 1); - assert_eq!(doc.node_name(result[0]), Some("div")); - } - - #[test] - fn test_id_no_match() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "#nonexistent"); - assert!(result.is_empty()); - } - - #[test] - fn test_id_with_tag() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "div#sidebar"); - assert_eq!(result.len(), 1); - assert_eq!(doc.attribute(result[0], "class"), Some("sidebar")); - } - - #[test] - fn test_id_multiple_ids_in_doc() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let main = eval(&doc, root, "#main"); - let sidebar = eval(&doc, root, "#sidebar"); - assert_eq!(main.len(), 1); - assert_eq!(sidebar.len(), 1); - assert_ne!(main[0], sidebar[0]); - } - - // --------------------------------------------------------------- - // 4. Attribute matching - // --------------------------------------------------------------- - - #[test] - fn test_attr_existence() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "[href]"); - assert_eq!(result.len(), 1); - assert_eq!(doc.node_name(result[0]), Some("a")); - } - - #[test] - fn test_attr_existence_no_match() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "[title]"); - assert!(result.is_empty()); - } - - #[test] - fn test_attr_exact_value() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "[data-type=\"external\"]"); - assert_eq!(result.len(), 1); - assert_eq!(doc.node_name(result[0]), Some("a")); - } - - #[test] - fn test_attr_exact_value_no_match() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "[data-type=\"internal\"]"); - assert!(result.is_empty()); - } - - #[test] - fn test_attr_prefix() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "[href^=\"https\"]"); - assert_eq!(result.len(), 1); - assert_eq!(doc.node_name(result[0]), Some("a")); - } - - #[test] - fn test_attr_prefix_no_match() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "[href^=\"ftp\"]"); - assert!(result.is_empty()); - } - - #[test] - fn test_attr_suffix() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "[src$=\".png\"]"); - assert_eq!(result.len(), 1); - assert_eq!(doc.node_name(result[0]), Some("img")); - } - - #[test] - fn test_attr_suffix_no_match() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "[src$=\".jpg\"]"); - assert!(result.is_empty()); - } - - #[test] - fn test_attr_substring() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "[href*=\"example\"]"); - assert_eq!(result.len(), 1); - assert_eq!(doc.node_name(result[0]), Some("a")); - } - - #[test] - fn test_attr_substring_no_match() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "[href*=\"missing\"]"); - assert!(result.is_empty()); - } - - #[test] - fn test_attr_word() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // class="container wide" — match the word "container" - let result = eval(&doc, root, "[class~=\"container\"]"); - assert_eq!(result.len(), 1); - assert_eq!(doc.attribute(result[0], "id"), Some("main")); - } - - #[test] - fn test_attr_dash_prefix_exact() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // lang="en" exactly matches [lang|="en"] - let result = eval(&doc, root, "[lang|=\"en\"]"); - // Should match both lang="en-US" and lang="en", but NOT lang="fr" - assert_eq!(result.len(), 2); - } - - #[test] - fn test_attr_dash_prefix_no_match() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "[lang|=\"de\"]"); - assert!(result.is_empty()); - } - - // --------------------------------------------------------------- - // 5. Pseudo-class matching - // --------------------------------------------------------------- - - #[test] - fn test_pseudo_first_child() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "li:first-child"); - assert_eq!(result.len(), 1); - assert_eq!(doc.text_content(result[0]), "One"); - } - - #[test] - fn test_pseudo_last_child() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "li:last-child"); - assert_eq!(result.len(), 1); - assert_eq!(doc.text_content(result[0]), "Three"); - } - - #[test] - fn test_pseudo_first_child_div() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // The first div child of root is #main - let result = eval(&doc, root, "div:first-child"); - assert_eq!(result.len(), 1); - assert_eq!(doc.attribute(result[0], "id"), Some("main")); - } - - #[test] - fn test_pseudo_empty() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, ":empty"); - // img and empty-div should be empty - let names: Vec<_> = result.iter().map(|&n| doc.node_name(n).unwrap()).collect(); - assert!(names.contains(&"img")); - assert!(names.contains(&"div")); // empty-div - } - - #[test] - fn test_pseudo_empty_excludes_non_empty() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, ":empty"); - // h1 has text content, should not match :empty - assert!(!result.iter().any(|&n| doc.node_name(n) == Some("h1"))); - } - - #[test] - fn test_pseudo_not_class() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "li:not(.active)"); - assert_eq!(result.len(), 2); - // Should be "Two" and "Three" - let texts: Vec<_> = result.iter().map(|&n| doc.text_content(n)).collect(); - assert!(texts.contains(&"Two".to_string())); - assert!(texts.contains(&"Three".to_string())); - } - - #[test] - fn test_pseudo_not_tag() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // All children of #main that are not <p> - let result = eval(&doc, root, "#main > :not(p)"); - assert!(!result.iter().any(|&n| doc.node_name(n) == Some("p"))); - assert!(result.len() >= 4); // h1, ul, a, img, span, span, span, div - } - - #[test] - fn test_pseudo_only_child() { - let doc = Document::parse_str( - r"<root><wrapper><only>Only child</only></wrapper><multi><a/><b/></multi></root>", - ) - .unwrap(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, ":only-child"); - assert_eq!(result.len(), 1); - assert_eq!(doc.node_name(result[0]), Some("only")); - } - - #[test] - fn test_pseudo_nth_child_specific() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // Second li - let result = eval(&doc, root, "li:nth-child(2)"); - assert_eq!(result.len(), 1); - assert_eq!(doc.text_content(result[0]), "Two"); - } - - #[test] - fn test_pseudo_nth_child_odd() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "li:nth-child(odd)"); - assert_eq!(result.len(), 2); // 1st and 3rd - assert_eq!(doc.text_content(result[0]), "One"); - assert_eq!(doc.text_content(result[1]), "Three"); - } - - #[test] - fn test_pseudo_nth_child_even() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "li:nth-child(even)"); - assert_eq!(result.len(), 1); // 2nd only - assert_eq!(doc.text_content(result[0]), "Two"); - } - - #[test] - fn test_pseudo_nth_last_child() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // :nth-last-child(1) is last child - let result = eval(&doc, root, "li:nth-last-child(1)"); - assert_eq!(result.len(), 1); - assert_eq!(doc.text_content(result[0]), "Three"); - } - - // --------------------------------------------------------------- - // 6. Combinator matching - // --------------------------------------------------------------- - - #[test] - fn test_combinator_descendant() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // All p descendants of div (any depth) - let result = eval(&doc, root, "div p"); - assert_eq!(result.len(), 3); // 2 in #main + 1 in #sidebar - } - - #[test] - fn test_combinator_descendant_deep() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // li is nested inside root > div > ul > li - let result = eval(&doc, root, "div li"); - assert_eq!(result.len(), 3); - } - - #[test] - fn test_combinator_child() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // Only direct children of #main that are <p> - let result = eval(&doc, root, "#main > p"); - assert_eq!(result.len(), 2); - } - - #[test] - fn test_combinator_child_excludes_deeper() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // li is NOT a direct child of div — it's a child of ul - let result = eval(&doc, root, "div > li"); - assert!(result.is_empty()); - } - - #[test] - fn test_combinator_adjacent_sibling() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // p immediately after h1 - let result = eval(&doc, root, "h1 + p"); - assert_eq!(result.len(), 1); - assert_eq!(doc.text_content(result[0]), "Hello"); - } - - #[test] - fn test_combinator_adjacent_sibling_no_match() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // h1 is not immediately preceded by a <p> - let result = eval(&doc, root, "p + h1"); - assert!(result.is_empty()); - } - - #[test] - fn test_combinator_general_sibling() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // All p elements that come after an h1 in the same parent - let result = eval(&doc, root, "h1 ~ p"); - assert_eq!(result.len(), 2); // both p's in #main - } - - #[test] - fn test_combinator_general_sibling_no_match() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // h1 has no preceding sibling <a> - let result = eval(&doc, root, "a ~ h1"); - assert!(result.is_empty()); - } - - #[test] - fn test_combinator_chain() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // Chain: div with class container > ul, then descendant li with class active - let result = eval(&doc, root, "div.container > ul li.active"); - assert_eq!(result.len(), 1); - assert_eq!(doc.text_content(result[0]), "One"); - } - - #[test] - fn test_combinator_three_levels() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // root > div > ul > li - let result = eval(&doc, root, "div > ul > li"); - assert_eq!(result.len(), 3); - } - - // --------------------------------------------------------------- - // 7. Universal selector matching - // --------------------------------------------------------------- - - #[test] - fn test_universal_all_elements() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "*"); - // Should match every element descendant of root - assert!(result.len() >= 14); // div, h1, p, p, ul, li, li, li, a, img, span, span, span, div, div, p - } - - #[test] - fn test_universal_direct_children() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // Direct children of #main - let result = eval(&doc, root, "#main > *"); - // h1, p, p, ul, a, img, span, span, span, empty-div - assert_eq!(result.len(), 10); - } - - #[test] - fn test_universal_with_class() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // Universal + class is equivalent to just .intro - let result_star = eval(&doc, root, "*.intro"); - let result_class = eval(&doc, root, ".intro"); - assert_eq!(result_star.len(), result_class.len()); - assert_eq!(result_star, result_class); - } - - #[test] - fn test_universal_with_pseudo() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "*:first-child"); - // First element child of each parent - assert!(result.len() >= 2); - // All returned nodes should be first element children of their parents - for &node in &result { - let parent = doc.parent(node).unwrap(); - let first = doc - .children(parent) - .find(|&c| matches!(doc.node(c).kind, NodeKind::Element { .. })) - .unwrap(); - assert_eq!(first, node); - } - } - - // --------------------------------------------------------------- - // Selector group (comma-separated) - // --------------------------------------------------------------- - - #[test] - fn test_selector_group() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "h1, img"); - assert_eq!(result.len(), 2); - let names: Vec<_> = result.iter().map(|&n| doc.node_name(n).unwrap()).collect(); - assert!(names.contains(&"h1")); - assert!(names.contains(&"img")); - } - - // --------------------------------------------------------------- - // Edge cases - // --------------------------------------------------------------- - - #[test] - fn test_empty_selector_group() { - let group = SelectorGroup { - selectors: vec![Selector { - compounds: Vec::new(), - }], - }; - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, &group); - assert!(result.is_empty()); - } - - #[test] - fn test_scope_limits_results() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // Get the sidebar div, then scope the search to it - let sidebar_nodes = eval(&doc, root, "#sidebar"); - assert_eq!(sidebar_nodes.len(), 1); - let sidebar = sidebar_nodes[0]; - // Only 1 <p> inside sidebar - let result = eval(&doc, sidebar, "p"); - assert_eq!(result.len(), 1); - assert_eq!(doc.text_content(result[0]), "Side"); - } - - #[test] - fn test_no_elements_in_scope() { - let doc = Document::parse_str("<root/>").unwrap(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "div"); - assert!(result.is_empty()); - } - - #[test] - fn test_fast_id_path_descendant_check() { - // The fast #id path should verify the node is a descendant of scope - let doc = test_doc(); - let root = doc.root_element().unwrap(); - // Get #sidebar, then search for #main from within it — should not find it - let sidebar_nodes = eval(&doc, root, "#sidebar"); - let sidebar = sidebar_nodes[0]; - let result = eval(&doc, sidebar, "#main"); - assert!(result.is_empty()); - } - - #[test] - fn test_document_order_preserved() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = eval(&doc, root, "li"); - assert_eq!(result.len(), 3); - assert_eq!(doc.text_content(result[0]), "One"); - assert_eq!(doc.text_content(result[1]), "Two"); - assert_eq!(doc.text_content(result[2]), "Three"); - } -} diff --git a/browser/vendor/xmloxide/src/css/mod.rs b/browser/vendor/xmloxide/src/css/mod.rs deleted file mode 100644 index 30d4ad414..000000000 --- a/browser/vendor/xmloxide/src/css/mod.rs +++ /dev/null @@ -1,340 +0,0 @@ -//! CSS selector engine for querying [`Document`] trees. -//! -//! Provides a familiar CSS selector API for finding elements in XML/HTML -//! documents, as an alternative to [`XPath`](crate::xpath). -//! -//! # Supported Selectors -//! -//! | Selector | Example | Description | -//! |----------|---------|-------------| -//! | Tag | `div` | Matches elements by tag name | -//! | Class | `.intro` | Matches elements with a class | -//! | ID | `#main` | Matches elements by id attribute | -//! | Universal | `*` | Matches any element | -//! | Attribute | `[href]` | Matches elements with an attribute | -//! | Attr value | `[type="text"]` | Exact attribute value match | -//! | Attr prefix | `[href^="https"]` | Attribute starts with value | -//! | Attr suffix | `[src$=".png"]` | Attribute ends with value | -//! | Attr substr | `[title*="hello"]` | Attribute contains value | -//! | Attr word | `[class~="active"]` | Whitespace-separated word match | -//! | Attr dash | `[lang\|="en"]` | Exact or dash-prefix match | -//! | Descendant | `div p` | `p` inside `div` (any depth) | -//! | Child | `div > p` | `p` directly inside `div` | -//! | Adjacent | `h1 + p` | `p` immediately after `h1` | -//! | General sibling | `h1 ~ p` | `p` after `h1` (same parent) | -//! | Group | `div, p` | Matches `div` or `p` | -//! | `:first-child` | `p:first-child` | First child element | -//! | `:last-child` | `p:last-child` | Last child element | -//! | `:only-child` | `p:only-child` | Only child element | -//! | `:empty` | `div:empty` | Element with no children | -//! | `:not()` | `:not(.hidden)` | Negation | -//! | `:nth-child()` | `:nth-child(2n+1)` | Position-based matching | -//! -//! # Examples -//! -//! ``` -//! use xmloxide::css::select; -//! use xmloxide::Document; -//! -//! let doc = Document::parse_str(r#" -//! <html> -//! <body> -//! <div class="content"> -//! <p id="intro">Hello</p> -//! <p class="highlight">World</p> -//! </div> -//! </body> -//! </html> -//! "#).unwrap(); -//! -//! let root = doc.root_element().unwrap(); -//! -//! // Find all paragraphs -//! let paragraphs = select(&doc, root, "p").unwrap(); -//! assert_eq!(paragraphs.len(), 2); -//! -//! // Find by class -//! let highlighted = select(&doc, root, ".highlight").unwrap(); -//! assert_eq!(highlighted.len(), 1); -//! assert_eq!(doc.text_content(highlighted[0]), "World"); -//! -//! // Find by ID -//! let intro = select(&doc, root, "#intro").unwrap(); -//! assert_eq!(intro.len(), 1); -//! -//! // Complex selector -//! let result = select(&doc, root, "div.content > p").unwrap(); -//! assert_eq!(result.len(), 2); -//! ``` - -mod eval; -pub mod parser; -pub mod types; - -pub use parser::CssSelectorError; -pub use types::SelectorGroup; - -use crate::tree::{Document, NodeId}; - -/// Select all descendant elements matching a CSS selector string. -/// -/// Parses the selector and evaluates it against all descendants of `scope`. -/// Returns matching nodes in document order. -/// -/// # Errors -/// -/// Returns a [`CssSelectorError`] if the selector string is malformed. -/// -/// # Examples -/// -/// ``` -/// use xmloxide::css::select; -/// use xmloxide::Document; -/// -/// let doc = Document::parse_str("<ul><li class=\"a\">1</li><li>2</li></ul>").unwrap(); -/// let root = doc.root_element().unwrap(); -/// let items = select(&doc, root, "li.a").unwrap(); -/// assert_eq!(items.len(), 1); -/// ``` -pub fn select( - doc: &Document, - scope: NodeId, - selector: &str, -) -> Result<Vec<NodeId>, CssSelectorError> { - let group = parser::parse_selector(selector)?; - Ok(eval::select(doc, scope, &group)) -} - -/// Select all descendant elements matching a pre-parsed selector group. -/// -/// Use this when evaluating the same selector against multiple documents -/// or scopes to avoid re-parsing the selector string. -pub fn select_with(doc: &Document, scope: NodeId, group: &SelectorGroup) -> Vec<NodeId> { - eval::select(doc, scope, group) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - - fn test_doc() -> Document { - Document::parse_str( - r#"<html> - <body> - <div id="main" class="container wide"> - <h1>Title</h1> - <p class="intro">Hello</p> - <p class="body">World</p> - <ul> - <li class="active">One</li> - <li>Two</li> - <li>Three</li> - </ul> - <a href="https://example.com">Link</a> - <img src="photo.png"/> - <span lang="en-US">English</span> - </div> - <div class="sidebar"> - <p>Side</p> - </div> - </body> - </html>"#, - ) - .unwrap() - } - - #[test] - fn test_select_by_tag() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let ps = select(&doc, root, "p").unwrap(); - assert_eq!(ps.len(), 3); - } - - #[test] - fn test_select_by_class() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, ".intro").unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(doc.text_content(result[0]), "Hello"); - } - - #[test] - fn test_select_by_id() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, "#main").unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(doc.node_name(result[0]), Some("div")); - } - - #[test] - fn test_select_descendant() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, "div p").unwrap(); - assert_eq!(result.len(), 3); // 2 in main + 1 in sidebar - } - - #[test] - fn test_select_child() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, "#main > p").unwrap(); - assert_eq!(result.len(), 2); - } - - #[test] - fn test_select_adjacent_sibling() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, "h1 + p").unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(doc.text_content(result[0]), "Hello"); - } - - #[test] - fn test_select_general_sibling() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, "h1 ~ p").unwrap(); - assert_eq!(result.len(), 2); - } - - #[test] - fn test_select_group() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, "h1, img").unwrap(); - assert_eq!(result.len(), 2); - } - - #[test] - fn test_select_attr_existence() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, "[href]").unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(doc.node_name(result[0]), Some("a")); - } - - #[test] - fn test_select_attr_prefix() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, "[href^=\"https\"]").unwrap(); - assert_eq!(result.len(), 1); - } - - #[test] - fn test_select_attr_suffix() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, "[src$=\".png\"]").unwrap(); - assert_eq!(result.len(), 1); - } - - #[test] - fn test_select_attr_dash_prefix() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, "[lang|=\"en\"]").unwrap(); - assert_eq!(result.len(), 1); - } - - #[test] - fn test_select_first_child() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, "li:first-child").unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(doc.text_content(result[0]), "One"); - } - - #[test] - fn test_select_last_child() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, "li:last-child").unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(doc.text_content(result[0]), "Three"); - } - - #[test] - fn test_select_not() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, "li:not(.active)").unwrap(); - assert_eq!(result.len(), 2); - } - - #[test] - fn test_select_nth_child_odd() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, "li:nth-child(odd)").unwrap(); - assert_eq!(result.len(), 2); // 1st and 3rd - } - - #[test] - fn test_select_empty() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, ":empty").unwrap(); - // img is self-closing / empty - assert!(result.iter().any(|&n| doc.node_name(n) == Some("img"))); - } - - #[test] - fn test_select_universal() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, "#main > *").unwrap(); - // All direct children of #main - assert!(result.len() >= 5); - } - - #[test] - fn test_select_multiple_classes() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, ".container.wide").unwrap(); - assert_eq!(result.len(), 1); - } - - #[test] - fn test_select_complex() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, "div.container > ul li.active").unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(doc.text_content(result[0]), "One"); - } - - #[test] - fn test_select_error() { - let doc = test_doc(); - let root = doc.root_element().unwrap(); - assert!(select(&doc, root, ">>>").is_err()); - } - - #[test] - fn test_id_map_auto_populated() { - // Verify element_by_id works without DTD validation - let doc = test_doc(); - let node = doc.element_by_id("main").unwrap(); - assert_eq!(doc.node_name(node), Some("div")); - } - - #[test] - fn test_fast_id_select() { - // Pure #id selector should use the fast path - let doc = test_doc(); - let root = doc.root_element().unwrap(); - let result = select(&doc, root, "#main").unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(doc.node_name(result[0]), Some("div")); - } -} diff --git a/browser/vendor/xmloxide/src/css/parser.rs b/browser/vendor/xmloxide/src/css/parser.rs deleted file mode 100644 index 0d02cc86e..000000000 --- a/browser/vendor/xmloxide/src/css/parser.rs +++ /dev/null @@ -1,580 +0,0 @@ -//! CSS selector parser. -//! -//! Hand-rolled recursive descent parser that converts a CSS selector string -//! into a [`SelectorGroup`] AST. - -use super::types::{ - AttrMatcher, AttrOp, AttrSelector, Combinator, CompoundEntry, CompoundSelector, NthExpr, - PseudoClass, Selector, SelectorGroup, -}; - -/// Parse error with position information. -#[derive(Debug, Clone)] -pub struct CssSelectorError { - /// Human-readable error message. - pub message: String, - /// Byte offset in the input where the error occurred. - pub position: usize, -} - -impl std::fmt::Display for CssSelectorError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "CSS selector error at {}: {}", - self.position, self.message - ) - } -} - -impl std::error::Error for CssSelectorError {} - -/// Parse a CSS selector string into a [`SelectorGroup`]. -/// -/// # Errors -/// -/// Returns a [`CssSelectorError`] if the selector string is malformed. -pub fn parse_selector(input: &str) -> Result<SelectorGroup, CssSelectorError> { - let mut parser = Parser::new(input); - parser.parse_selector_group() -} - -struct Parser<'a> { - input: &'a str, - pos: usize, -} - -impl<'a> Parser<'a> { - fn new(input: &'a str) -> Self { - Self { input, pos: 0 } - } - - fn remaining(&self) -> &'a str { - &self.input[self.pos..] - } - - fn peek(&self) -> Option<char> { - self.remaining().chars().next() - } - - fn advance(&mut self, n: usize) { - self.pos += n; - } - - fn skip_whitespace(&mut self) { - while self.peek().is_some_and(|c| c.is_ascii_whitespace()) { - self.advance(1); - } - } - - fn at_end(&self) -> bool { - self.pos >= self.input.len() - } - - fn err(&self, msg: impl Into<String>) -> CssSelectorError { - CssSelectorError { - message: msg.into(), - position: self.pos, - } - } - - // --- Grammar --- - - fn parse_selector_group(&mut self) -> Result<SelectorGroup, CssSelectorError> { - let mut selectors = vec![self.parse_selector()?]; - loop { - self.skip_whitespace(); - if self.peek() == Some(',') { - self.advance(1); - self.skip_whitespace(); - selectors.push(self.parse_selector()?); - } else { - break; - } - } - if !self.at_end() { - return Err(self.err(format!( - "unexpected character '{}'", - self.peek().unwrap_or('?') - ))); - } - Ok(SelectorGroup { selectors }) - } - - fn parse_selector(&mut self) -> Result<Selector, CssSelectorError> { - let first = self.parse_compound()?; - let mut compounds = vec![CompoundEntry { - combinator: Combinator::None, - compound: first, - }]; - - loop { - let had_ws = self.skip_ws_and_check(); - if self.at_end() || self.peek() == Some(',') { - break; - } - - let combinator = if self.peek() == Some('>') { - self.advance(1); - self.skip_whitespace(); - Combinator::Child - } else if self.peek() == Some('+') { - self.advance(1); - self.skip_whitespace(); - Combinator::NextSibling - } else if self.peek() == Some('~') { - self.advance(1); - self.skip_whitespace(); - Combinator::SubsequentSibling - } else if had_ws { - Combinator::Descendant - } else { - break; - }; - - compounds.push(CompoundEntry { - combinator, - compound: self.parse_compound()?, - }); - } - - Ok(Selector { compounds }) - } - - /// Skip whitespace and return whether any was skipped. - fn skip_ws_and_check(&mut self) -> bool { - let before = self.pos; - self.skip_whitespace(); - self.pos > before - } - - fn parse_compound(&mut self) -> Result<CompoundSelector, CssSelectorError> { - let mut compound = CompoundSelector::default(); - let mut has_component = false; - - // Optional tag name or * - if self - .peek() - .is_some_and(|c| c.is_ascii_alphabetic() || c == '*') - { - if self.peek() == Some('*') { - self.advance(1); - // Universal selector — tag stays None but is still a valid component - } else { - compound.tag = Some(self.parse_ident()?); - } - has_component = true; - } - - // Simple selectors: #id, .class, [attr], :pseudo - loop { - match self.peek() { - Some('#') => { - self.advance(1); - compound.id = Some(self.parse_ident()?); - has_component = true; - } - Some('.') => { - self.advance(1); - compound.classes.push(self.parse_ident()?); - has_component = true; - } - Some('[') => { - compound.attrs.push(self.parse_attr_selector()?); - has_component = true; - } - Some(':') => { - compound.pseudos.push(self.parse_pseudo_class()?); - has_component = true; - } - _ => break, - } - } - - if !has_component { - return Err(self.err("expected selector")); - } - - Ok(compound) - } - - fn parse_ident(&mut self) -> Result<String, CssSelectorError> { - let start = self.pos; - while self - .peek() - .is_some_and(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') - { - self.advance(self.peek().unwrap_or(' ').len_utf8()); - } - if self.pos == start { - return Err(self.err("expected identifier")); - } - Ok(self.input[start..self.pos].to_string()) - } - - fn parse_attr_selector(&mut self) -> Result<AttrSelector, CssSelectorError> { - self.advance(1); // consume '[' - self.skip_whitespace(); - - let name = self.parse_ident()?; - self.skip_whitespace(); - - let matcher = if self.peek() == Some(']') { - None - } else { - let op = self.parse_attr_op()?; - self.skip_whitespace(); - let value = self.parse_attr_value()?; - self.skip_whitespace(); - let case_insensitive = if self.peek() == Some('i') || self.peek() == Some('I') { - self.advance(1); - self.skip_whitespace(); - true - } else { - false - }; - Some(AttrMatcher { - op, - value, - case_insensitive, - }) - }; - - if self.peek() != Some(']') { - return Err(self.err("expected ']'")); - } - self.advance(1); - - Ok(AttrSelector { name, matcher }) - } - - fn parse_attr_op(&mut self) -> Result<AttrOp, CssSelectorError> { - let op = match self.peek() { - Some('=') => { - self.advance(1); - AttrOp::Exact - } - Some('~') => { - self.advance(1); - self.expect_char('=')?; - AttrOp::Word - } - Some('|') => { - self.advance(1); - self.expect_char('=')?; - AttrOp::DashPrefix - } - Some('^') => { - self.advance(1); - self.expect_char('=')?; - AttrOp::Prefix - } - Some('$') => { - self.advance(1); - self.expect_char('=')?; - AttrOp::Suffix - } - Some('*') => { - self.advance(1); - self.expect_char('=')?; - AttrOp::Substring - } - _ => return Err(self.err("expected attribute operator")), - }; - Ok(op) - } - - fn parse_attr_value(&mut self) -> Result<String, CssSelectorError> { - match self.peek() { - Some(quote @ ('"' | '\'')) => { - self.advance(1); - let start = self.pos; - while self.peek().is_some_and(|c| c != quote) { - self.advance(self.peek().unwrap_or(' ').len_utf8()); - } - let value = self.input[start..self.pos].to_string(); - self.expect_char(quote)?; - Ok(value) - } - _ => self.parse_ident(), - } - } - - fn parse_pseudo_class(&mut self) -> Result<PseudoClass, CssSelectorError> { - self.advance(1); // consume ':' - let name = self.parse_ident()?; - - match name.as_str() { - "first-child" => Ok(PseudoClass::FirstChild), - "last-child" => Ok(PseudoClass::LastChild), - "only-child" => Ok(PseudoClass::OnlyChild), - "empty" => Ok(PseudoClass::Empty), - "not" => { - self.expect_char('(')?; - self.skip_whitespace(); - let inner = self.parse_compound()?; - self.skip_whitespace(); - self.expect_char(')')?; - Ok(PseudoClass::Not(Box::new(inner))) - } - "nth-child" => { - self.expect_char('(')?; - let expr = self.parse_nth_expr()?; - self.expect_char(')')?; - Ok(PseudoClass::NthChild(expr)) - } - "nth-last-child" => { - self.expect_char('(')?; - let expr = self.parse_nth_expr()?; - self.expect_char(')')?; - Ok(PseudoClass::NthLastChild(expr)) - } - _ => Err(self.err(format!("unknown pseudo-class ':{name}'"))), - } - } - - fn parse_nth_expr(&mut self) -> Result<NthExpr, CssSelectorError> { - self.skip_whitespace(); - - // Handle keywords: odd, even - if self.remaining().starts_with("odd") { - self.advance(3); - self.skip_whitespace(); - return Ok(NthExpr { a: 2, b: 1 }); - } - if self.remaining().starts_with("even") { - self.advance(4); - self.skip_whitespace(); - return Ok(NthExpr { a: 2, b: 0 }); - } - - // Parse An+B - let neg = self.peek() == Some('-'); - if neg || self.peek() == Some('+') { - self.advance(1); - } - - // Check for 'n' without leading number (means 1n or -1n) - if self.peek() == Some('n') { - self.advance(1); - let a = if neg { -1 } else { 1 }; - let b = self.parse_nth_offset()?; - self.skip_whitespace(); - return Ok(NthExpr { a, b }); - } - - // Parse number - let num = self.parse_int()?; - let num = if neg { -num } else { num }; - - if self.peek() == Some('n') { - self.advance(1); - let b = self.parse_nth_offset()?; - self.skip_whitespace(); - Ok(NthExpr { a: num, b }) - } else { - self.skip_whitespace(); - Ok(NthExpr { a: 0, b: num }) - } - } - - fn parse_nth_offset(&mut self) -> Result<i32, CssSelectorError> { - self.skip_whitespace(); - match self.peek() { - Some('+') => { - self.advance(1); - self.skip_whitespace(); - self.parse_int() - } - Some('-') => { - self.advance(1); - self.skip_whitespace(); - self.parse_int().map(|n| -n) - } - _ => Ok(0), - } - } - - fn parse_int(&mut self) -> Result<i32, CssSelectorError> { - let start = self.pos; - while self.peek().is_some_and(|c| c.is_ascii_digit()) { - self.advance(1); - } - if self.pos == start { - return Err(self.err("expected number")); - } - self.input[start..self.pos] - .parse() - .map_err(|_| self.err("invalid number")) - } - - fn expect_char(&mut self, expected: char) -> Result<(), CssSelectorError> { - if self.peek() == Some(expected) { - self.advance(expected.len_utf8()); - Ok(()) - } else { - Err(self.err(format!( - "expected '{expected}', got '{}'", - self.peek().unwrap_or('?') - ))) - } - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - - #[test] - fn test_tag_selector() { - let sg = parse_selector("div").unwrap(); - assert_eq!(sg.selectors.len(), 1); - let s = &sg.selectors[0]; - assert_eq!(s.compounds.len(), 1); - assert_eq!(s.compounds[0].compound.tag.as_deref(), Some("div")); - } - - #[test] - fn test_class_selector() { - let sg = parse_selector(".intro").unwrap(); - assert_eq!(sg.selectors[0].compounds[0].compound.classes, vec!["intro"]); - } - - #[test] - fn test_id_selector() { - let sg = parse_selector("#main").unwrap(); - assert_eq!( - sg.selectors[0].compounds[0].compound.id.as_deref(), - Some("main") - ); - } - - #[test] - fn test_compound_selector() { - let sg = parse_selector("div.intro#first").unwrap(); - let c = &sg.selectors[0].compounds[0].compound; - assert_eq!(c.tag.as_deref(), Some("div")); - assert_eq!(c.classes, vec!["intro"]); - assert_eq!(c.id.as_deref(), Some("first")); - } - - #[test] - fn test_descendant_combinator() { - let sg = parse_selector("div p").unwrap(); - assert_eq!(sg.selectors[0].compounds.len(), 2); - assert_eq!( - sg.selectors[0].compounds[1].combinator, - Combinator::Descendant - ); - } - - #[test] - fn test_child_combinator() { - let sg = parse_selector("div > p").unwrap(); - assert_eq!(sg.selectors[0].compounds[1].combinator, Combinator::Child); - } - - #[test] - fn test_sibling_combinators() { - let sg = parse_selector("div + p").unwrap(); - assert_eq!( - sg.selectors[0].compounds[1].combinator, - Combinator::NextSibling - ); - - let sg = parse_selector("div ~ p").unwrap(); - assert_eq!( - sg.selectors[0].compounds[1].combinator, - Combinator::SubsequentSibling - ); - } - - #[test] - fn test_selector_group() { - let sg = parse_selector("div, p, span").unwrap(); - assert_eq!(sg.selectors.len(), 3); - } - - #[test] - fn test_attr_existence() { - let sg = parse_selector("[href]").unwrap(); - let attr = &sg.selectors[0].compounds[0].compound.attrs[0]; - assert_eq!(attr.name, "href"); - assert!(attr.matcher.is_none()); - } - - #[test] - fn test_attr_exact() { - let sg = parse_selector("[type=\"text\"]").unwrap(); - let attr = &sg.selectors[0].compounds[0].compound.attrs[0]; - assert_eq!(attr.name, "type"); - let m = attr.matcher.as_ref().unwrap(); - assert_eq!(m.op, AttrOp::Exact); - assert_eq!(m.value, "text"); - } - - #[test] - fn test_attr_prefix() { - let sg = parse_selector("[href^=\"https\"]").unwrap(); - let m = sg.selectors[0].compounds[0].compound.attrs[0] - .matcher - .as_ref() - .unwrap(); - assert_eq!(m.op, AttrOp::Prefix); - assert_eq!(m.value, "https"); - } - - #[test] - fn test_pseudo_first_child() { - let sg = parse_selector("p:first-child").unwrap(); - assert!(matches!( - sg.selectors[0].compounds[0].compound.pseudos[0], - PseudoClass::FirstChild - )); - } - - #[test] - fn test_pseudo_not() { - let sg = parse_selector(":not(.hidden)").unwrap(); - if let PseudoClass::Not(inner) = &sg.selectors[0].compounds[0].compound.pseudos[0] { - assert_eq!(inner.classes, vec!["hidden"]); - } else { - panic!("expected :not()"); - } - } - - #[test] - fn test_pseudo_nth_child() { - let sg = parse_selector(":nth-child(2n+1)").unwrap(); - if let PseudoClass::NthChild(expr) = &sg.selectors[0].compounds[0].compound.pseudos[0] { - assert_eq!(expr.a, 2); - assert_eq!(expr.b, 1); - } else { - panic!("expected :nth-child()"); - } - } - - #[test] - fn test_pseudo_nth_child_odd() { - let sg = parse_selector(":nth-child(odd)").unwrap(); - if let PseudoClass::NthChild(expr) = &sg.selectors[0].compounds[0].compound.pseudos[0] { - assert_eq!(expr.a, 2); - assert_eq!(expr.b, 1); - } else { - panic!("expected :nth-child()"); - } - } - - #[test] - fn test_universal_selector() { - let sg = parse_selector("*").unwrap(); - assert!(sg.selectors[0].compounds[0].compound.tag.is_none()); - } - - #[test] - fn test_complex_selector() { - let sg = parse_selector("div.container > ul.nav li.active a[href]").unwrap(); - assert_eq!(sg.selectors[0].compounds.len(), 4); - } -} diff --git a/browser/vendor/xmloxide/src/css/types.rs b/browser/vendor/xmloxide/src/css/types.rs deleted file mode 100644 index 0492adef5..000000000 --- a/browser/vendor/xmloxide/src/css/types.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! CSS selector AST types. - -/// A group of selectors separated by commas: `div, p.intro` -#[derive(Debug, Clone)] -pub struct SelectorGroup { - /// Individual selectors in the group. - pub selectors: Vec<Selector>, -} - -/// A single selector: a chain of compound selectors joined by combinators. -/// -/// For example, `div > p.intro` is a chain of two compounds: -/// `div` (followed by child combinator) and `p.intro`. -#[derive(Debug, Clone)] -pub struct Selector { - /// The chain of compound selectors and combinators. - pub compounds: Vec<CompoundEntry>, -} - -/// An entry in the selector chain: a compound selector with its leading combinator. -#[derive(Debug, Clone)] -pub struct CompoundEntry { - /// How this compound relates to the previous one. - /// The first entry in a chain uses `Combinator::None`. - pub combinator: Combinator, - /// The compound selector itself. - pub compound: CompoundSelector, -} - -/// A compound selector: a set of simple selectors that all apply to the same element. -/// -/// For example, `p.intro#first[lang]` has tag=`p`, classes=\[`intro`\], -/// id=`first`, and attrs=\[`lang`\]. -#[derive(Debug, Clone, Default)] -pub struct CompoundSelector { - /// Tag name matcher (e.g., `div`). `None` means any tag (implicit `*`). - pub tag: Option<String>, - /// ID matcher (e.g., `#main`). - pub id: Option<String>, - /// Class matchers (e.g., `.intro`). - pub classes: Vec<String>, - /// Attribute matchers (e.g., `[href^="https"]`). - pub attrs: Vec<AttrSelector>, - /// Pseudo-class matchers (e.g., `:first-child`). - pub pseudos: Vec<PseudoClass>, -} - -/// Combinator between compound selectors. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Combinator { - /// No combinator (first in chain). - None, - /// Descendant combinator (whitespace): `div p` - Descendant, - /// Child combinator: `div > p` - Child, - /// Adjacent sibling combinator: `div + p` - NextSibling, - /// General sibling combinator: `div ~ p` - SubsequentSibling, -} - -/// An attribute selector: `[attr]`, `[attr=value]`, `[attr^=value]`, etc. -#[derive(Debug, Clone)] -pub struct AttrSelector { - /// Attribute name. - pub name: String, - /// Match operator and value. `None` means just `[attr]` (existence check). - pub matcher: Option<AttrMatcher>, -} - -/// Attribute value matching operator and value. -#[derive(Debug, Clone)] -pub struct AttrMatcher { - /// The match operator. - pub op: AttrOp, - /// The value to match against. - pub value: String, - /// Case-insensitive flag (`i` modifier). - pub case_insensitive: bool, -} - -/// Attribute match operators. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AttrOp { - /// `=` — exact match - Exact, - /// `~=` — whitespace-separated word match - Word, - /// `|=` — exact or prefix followed by `-` - DashPrefix, - /// `^=` — starts with - Prefix, - /// `$=` — ends with - Suffix, - /// `*=` — contains substring - Substring, -} - -/// Pseudo-class selectors. -#[derive(Debug, Clone)] -pub enum PseudoClass { - /// `:first-child` - FirstChild, - /// `:last-child` - LastChild, - /// `:only-child` - OnlyChild, - /// `:empty` - Empty, - /// `:not(selector)` - Not(Box<CompoundSelector>), - /// `:nth-child(An+B)` - NthChild(NthExpr), - /// `:nth-last-child(An+B)` - NthLastChild(NthExpr), -} - -/// An `An+B` expression for `:nth-child()` and similar. -#[derive(Debug, Clone, Copy)] -pub struct NthExpr { - /// The `A` coefficient (0 for just `B`). - pub a: i32, - /// The `B` offset. - pub b: i32, -} - -impl NthExpr { - /// Returns true if the 1-based position `pos` matches this `An+B` expression. - pub fn matches(&self, pos: i32) -> bool { - if self.a == 0 { - return pos == self.b; - } - let diff = pos - self.b; - // diff must be divisible by a and have the same sign - diff % self.a == 0 && diff / self.a >= 0 - } -} diff --git a/browser/vendor/xmloxide/src/ffi/c14n.rs b/browser/vendor/xmloxide/src/ffi/c14n.rs deleted file mode 100644 index 9247f6355..000000000 --- a/browser/vendor/xmloxide/src/ffi/c14n.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! Canonical XML (C14N) serialization FFI functions. -#![allow(unsafe_code, clippy::missing_safety_doc)] - -use std::os::raw::c_char; - -use crate::serial::c14n::{self, C14nOptions}; -use crate::tree::{Document, NodeId}; - -use super::strings::to_c_string; -use super::{clear_last_error, set_last_error}; - -/// Canonicalizes a document using inclusive C14N with comments. -/// -/// Returns a caller-owned C string that must be freed with -/// `xmloxide_free_string`. Returns null on failure. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_canonicalize(doc: *const Document) -> *mut c_char { - clear_last_error(); - if doc.is_null() { - set_last_error("null document pointer"); - return std::ptr::null_mut(); - } - // SAFETY: Null check above. - let doc = unsafe { &*doc }; - let output = c14n::canonicalize(doc, &C14nOptions::default()); - to_c_string(&output) -} - -/// Canonicalizes a document with options. -/// -/// `with_comments`: 1 to include comments, 0 to strip. -/// `exclusive`: 1 for exclusive C14N, 0 for inclusive. -/// -/// Returns a caller-owned C string that must be freed with -/// `xmloxide_free_string`. Returns null on failure. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_canonicalize_opts( - doc: *const Document, - with_comments: i32, - exclusive: i32, -) -> *mut c_char { - clear_last_error(); - if doc.is_null() { - set_last_error("null document pointer"); - return std::ptr::null_mut(); - } - // SAFETY: Null check above. - let doc = unsafe { &*doc }; - let opts = C14nOptions { - with_comments: with_comments != 0, - exclusive: exclusive != 0, - inclusive_prefixes: vec![], - }; - let output = c14n::canonicalize(doc, &opts); - to_c_string(&output) -} - -/// Canonicalizes a subtree rooted at the given node. -/// -/// Returns a caller-owned C string that must be freed with -/// `xmloxide_free_string`. Returns null on failure. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_canonicalize_subtree( - doc: *const Document, - node: u32, - with_comments: i32, - exclusive: i32, -) -> *mut c_char { - clear_last_error(); - if doc.is_null() { - set_last_error("null document pointer"); - return std::ptr::null_mut(); - } - let Some(node_id) = NodeId::from_raw(node) else { - set_last_error("invalid node id"); - return std::ptr::null_mut(); - }; - // SAFETY: Null check above. - let doc = unsafe { &*doc }; - let opts = C14nOptions { - with_comments: with_comments != 0, - exclusive: exclusive != 0, - inclusive_prefixes: vec![], - }; - let output = c14n::canonicalize_subtree(doc, node_id, &opts); - to_c_string(&output) -} diff --git a/browser/vendor/xmloxide/src/ffi/catalog.rs b/browser/vendor/xmloxide/src/ffi/catalog.rs deleted file mode 100644 index bb863f969..000000000 --- a/browser/vendor/xmloxide/src/ffi/catalog.rs +++ /dev/null @@ -1,144 +0,0 @@ -//! XML Catalog FFI functions. -#![allow(unsafe_code, clippy::missing_safety_doc)] - -use std::ffi::CStr; -use std::os::raw::c_char; - -use crate::catalog::Catalog; - -use super::strings::to_c_string; -use super::{clear_last_error, set_last_error}; - -/// Parses an XML Catalog from a null-terminated UTF-8 XML string. -/// -/// Returns a pointer to the catalog on success, or null on failure. -/// The returned catalog must be freed with [`xmloxide_free_catalog`]. -/// -/// # Safety -/// -/// `input` must be a valid null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_parse_catalog(input: *const c_char) -> *mut Catalog { - clear_last_error(); - if input.is_null() { - set_last_error("null input pointer"); - return std::ptr::null_mut(); - } - // SAFETY: Null check above. - let c_str = unsafe { CStr::from_ptr(input) }; - let Ok(s) = c_str.to_str() else { - set_last_error("invalid UTF-8"); - return std::ptr::null_mut(); - }; - match Catalog::parse(s) { - Ok(cat) => Box::into_raw(Box::new(cat)), - Err(e) => { - set_last_error(&e.message); - std::ptr::null_mut() - } - } -} - -/// Frees a catalog previously returned by `xmloxide_parse_catalog`. -/// -/// Passing null is safe and does nothing. -/// -/// # Safety -/// -/// `catalog` must have been returned by `xmloxide_parse_catalog`, or be null. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_free_catalog(catalog: *mut Catalog) { - if !catalog.is_null() { - // SAFETY: `catalog` was created by `Box::into_raw`, and is non-null. - unsafe { - drop(Box::from_raw(catalog)); - } - } -} - -/// Resolves a system identifier using the catalog. -/// -/// Returns a caller-owned C string with the resolved URI, or null if not found. -/// The returned string must be freed with `xmloxide_free_string`. -/// -/// # Safety -/// -/// `catalog` must be a valid catalog pointer. `system_id` must be a valid -/// null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_catalog_resolve_system( - catalog: *const Catalog, - system_id: *const c_char, -) -> *mut c_char { - if catalog.is_null() || system_id.is_null() { - return std::ptr::null_mut(); - } - // SAFETY: Null checks above. - let catalog = unsafe { &*catalog }; - let c_id = unsafe { CStr::from_ptr(system_id) }; - let Ok(id_str) = c_id.to_str() else { - return std::ptr::null_mut(); - }; - match catalog.resolve_system(id_str) { - Some(resolved) => to_c_string(&resolved), - None => std::ptr::null_mut(), - } -} - -/// Resolves a public identifier using the catalog. -/// -/// Returns a caller-owned C string with the resolved URI, or null if not found. -/// The returned string must be freed with `xmloxide_free_string`. -/// -/// # Safety -/// -/// `catalog` must be a valid catalog pointer. `public_id` must be a valid -/// null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_catalog_resolve_public( - catalog: *const Catalog, - public_id: *const c_char, -) -> *mut c_char { - if catalog.is_null() || public_id.is_null() { - return std::ptr::null_mut(); - } - // SAFETY: Null checks above. - let catalog = unsafe { &*catalog }; - let c_id = unsafe { CStr::from_ptr(public_id) }; - let Ok(id_str) = c_id.to_str() else { - return std::ptr::null_mut(); - }; - match catalog.resolve_public(id_str) { - Some(resolved) => to_c_string(&resolved), - None => std::ptr::null_mut(), - } -} - -/// Resolves a URI using the catalog. -/// -/// Returns a caller-owned C string with the resolved URI, or null if not found. -/// The returned string must be freed with `xmloxide_free_string`. -/// -/// # Safety -/// -/// `catalog` must be a valid catalog pointer. `uri` must be a valid -/// null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_catalog_resolve_uri( - catalog: *const Catalog, - uri: *const c_char, -) -> *mut c_char { - if catalog.is_null() || uri.is_null() { - return std::ptr::null_mut(); - } - // SAFETY: Null checks above. - let catalog = unsafe { &*catalog }; - let c_uri = unsafe { CStr::from_ptr(uri) }; - let Ok(uri_str) = c_uri.to_str() else { - return std::ptr::null_mut(); - }; - match catalog.resolve_uri(uri_str) { - Some(resolved) => to_c_string(&resolved), - None => std::ptr::null_mut(), - } -} diff --git a/browser/vendor/xmloxide/src/ffi/css.rs b/browser/vendor/xmloxide/src/ffi/css.rs deleted file mode 100644 index 66ded298e..000000000 --- a/browser/vendor/xmloxide/src/ffi/css.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! CSS selector FFI functions. -#![allow(unsafe_code, clippy::missing_safety_doc)] - -use std::ffi::CStr; -use std::os::raw::c_char; - -use crate::css; -use crate::tree::Document; - -use super::{clear_last_error, set_last_error}; - -/// Evaluates a CSS selector against a subtree and returns matching node IDs. -/// -/// `scope` is the node to search within (typically the root element). -/// `selector` is a null-terminated UTF-8 CSS selector string. -/// -/// On success, sets `*out_count` to the number of matching nodes and returns -/// a heap-allocated array of `uint32_t` node IDs. The caller must free the -/// array with [`xmloxide_free_nodeid_array`]. -/// -/// Returns null on failure (parse error in selector or null arguments). -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. `selector` must be a valid -/// null-terminated UTF-8 string. `out_count` must be a valid pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_css_select( - doc: *const Document, - scope: u32, - selector: *const c_char, - out_count: *mut usize, -) -> *mut u32 { - clear_last_error(); - if doc.is_null() || selector.is_null() || out_count.is_null() { - set_last_error("null pointer argument"); - return std::ptr::null_mut(); - } - - // SAFETY: Null checks above. - let doc_ref = unsafe { &*doc }; - let css_sel = unsafe { CStr::from_ptr(selector) }; - let Ok(sel) = css_sel.to_str() else { - set_last_error("invalid UTF-8 in selector"); - return std::ptr::null_mut(); - }; - - let Some(scope_id) = crate::tree::NodeId::from_raw(scope) else { - set_last_error("invalid scope node id"); - return std::ptr::null_mut(); - }; - - match css::select(doc_ref, scope_id, sel) { - Ok(nodes) => { - let ids: Vec<u32> = nodes.iter().map(|n| n.into_raw()).collect(); - let len = ids.len(); - // SAFETY: out_count was checked non-null. - unsafe { *out_count = len }; - if ids.is_empty() { - // Return a non-null sentinel for empty results that can safely - // be freed (dangling pointer with zero length). - return std::ptr::NonNull::dangling().as_ptr(); - } - let boxed = ids.into_boxed_slice(); - Box::into_raw(boxed).cast::<u32>() - } - Err(e) => { - set_last_error(&e.to_string()); - std::ptr::null_mut() - } - } -} - -/// Frees a node ID array returned by [`xmloxide_css_select`]. -/// -/// Passing null is safe and does nothing. -/// -/// # Safety -/// -/// `ptr` must have been returned by `xmloxide_css_select` with the -/// corresponding `count`, or be null. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_free_nodeid_array(ptr: *mut u32, count: usize) { - if !ptr.is_null() && count > 0 { - // SAFETY: Pointer and count were returned by `xmloxide_css_select`. - unsafe { - let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, count)); - } - } -} - -/// Returns the first node matching a CSS selector, or 0 if none found. -/// -/// This is a convenience wrapper — it evaluates the selector and returns -/// only the first match. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. `selector` must be a valid -/// null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_css_select_first( - doc: *const Document, - scope: u32, - selector: *const c_char, -) -> u32 { - clear_last_error(); - if doc.is_null() || selector.is_null() { - set_last_error("null pointer argument"); - return 0; - } - - // SAFETY: Null checks above. - let doc_ref = unsafe { &*doc }; - let css_sel = unsafe { CStr::from_ptr(selector) }; - let Ok(sel) = css_sel.to_str() else { - set_last_error("invalid UTF-8 in selector"); - return 0; - }; - - let Some(scope_id) = crate::tree::NodeId::from_raw(scope) else { - set_last_error("invalid scope node id"); - return 0; - }; - - match css::select(doc_ref, scope_id, sel) { - Ok(nodes) => nodes.first().map_or(0, |n| n.into_raw()), - Err(e) => { - set_last_error(&e.to_string()); - 0 - } - } -} diff --git a/browser/vendor/xmloxide/src/ffi/document.rs b/browser/vendor/xmloxide/src/ffi/document.rs deleted file mode 100644 index c7413a64b..000000000 --- a/browser/vendor/xmloxide/src/ffi/document.rs +++ /dev/null @@ -1,328 +0,0 @@ -//! Document parsing and lifecycle FFI functions. -#![allow(unsafe_code, clippy::missing_safety_doc)] - -use std::ffi::CStr; -use std::os::raw::c_char; - -use crate::tree::Document; - -use crate::error::ErrorSeverity; - -use super::strings::to_c_string; -use super::{ - clear_last_error, set_last_error, set_last_error_structured, XMLOXIDE_ERR_ERROR, - XMLOXIDE_ERR_FATAL, XMLOXIDE_ERR_WARNING, -}; - -/// Parses a null-terminated UTF-8 XML string into a document. -/// -/// Returns a pointer to the document on success, or null on failure. -/// On failure, call [`xmloxide_last_error`](super::xmloxide_last_error) for details. -/// -/// The returned document must be freed with [`xmloxide_free_doc`]. -/// -/// # Safety -/// -/// `input` must be a valid null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_parse_str(input: *const c_char) -> *mut Document { - clear_last_error(); - if input.is_null() { - set_last_error("null input pointer"); - return std::ptr::null_mut(); - } - // SAFETY: Null check above. Caller guarantees `input` is a valid null-terminated string. - let c_str = unsafe { CStr::from_ptr(input) }; - let s = match c_str.to_str() { - Ok(s) => s, - Err(e) => { - set_last_error(&format!("invalid UTF-8: {e}")); - return std::ptr::null_mut(); - } - }; - match Document::parse_str(s) { - Ok(doc) => Box::into_raw(Box::new(doc)), - Err(e) => { - set_last_error_structured( - &e.message, - e.location.line, - e.location.column, - XMLOXIDE_ERR_FATAL, - ); - std::ptr::null_mut() - } - } -} - -/// Parses raw bytes as XML, with automatic encoding detection. -/// -/// Returns a pointer to the document on success, or null on failure. -/// On failure, call [`xmloxide_last_error`](super::xmloxide_last_error) for details. -/// -/// The returned document must be freed with [`xmloxide_free_doc`]. -/// -/// # Safety -/// -/// `data` must point to `len` valid bytes. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_parse_bytes(data: *const u8, len: usize) -> *mut Document { - clear_last_error(); - if data.is_null() { - set_last_error("null data pointer"); - return std::ptr::null_mut(); - } - // SAFETY: Null check above. Caller guarantees `data` points to `len` valid bytes. - let bytes = unsafe { std::slice::from_raw_parts(data, len) }; - match Document::parse_bytes(bytes) { - Ok(doc) => Box::into_raw(Box::new(doc)), - Err(e) => { - set_last_error_structured( - &e.message, - e.location.line, - e.location.column, - XMLOXIDE_ERR_FATAL, - ); - std::ptr::null_mut() - } - } -} - -/// Frees a document previously returned by a parse function. -/// -/// Passing null is safe and does nothing. -/// -/// # Safety -/// -/// `doc` must have been returned by `xmloxide_parse_str` or -/// `xmloxide_parse_bytes`, or be null. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_free_doc(doc: *mut Document) { - if !doc.is_null() { - // SAFETY: `doc` was created by `Box::into_raw` in a parse function, and is non-null. - unsafe { - drop(Box::from_raw(doc)); - } - } -} - -/// Returns the XML version string from the document's XML declaration. -/// -/// Returns null if no version was declared. The returned string must -/// be freed with [`xmloxide_free_string`](super::strings::xmloxide_free_string). -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_doc_version(doc: *const Document) -> *mut c_char { - if doc.is_null() { - return std::ptr::null_mut(); - } - // SAFETY: Null check above. Caller guarantees `doc` is a valid pointer from a parse function. - let doc = unsafe { &*doc }; - match &doc.version { - Some(v) => to_c_string(v), - None => std::ptr::null_mut(), - } -} - -/// Returns the encoding string from the document's XML declaration. -/// -/// Returns null if no encoding was declared. The returned string must -/// be freed with [`xmloxide_free_string`](super::strings::xmloxide_free_string). -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_doc_encoding(doc: *const Document) -> *mut c_char { - if doc.is_null() { - return std::ptr::null_mut(); - } - // SAFETY: Null check above. Caller guarantees `doc` is a valid pointer from a parse function. - let doc = unsafe { &*doc }; - match &doc.encoding { - Some(e) => to_c_string(e), - None => std::ptr::null_mut(), - } -} - -/// Parses an HTML string into a document. -/// -/// Returns a pointer to the document on success, or null on failure. -/// The returned document must be freed with [`xmloxide_free_doc`]. -/// -/// # Safety -/// -/// `input` must be a valid null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_parse_html(input: *const c_char) -> *mut Document { - clear_last_error(); - if input.is_null() { - set_last_error("null input pointer"); - return std::ptr::null_mut(); - } - // SAFETY: Null check above. Caller guarantees valid null-terminated string. - let c_str = unsafe { CStr::from_ptr(input) }; - let s = match c_str.to_str() { - Ok(s) => s, - Err(e) => { - set_last_error(&format!("invalid UTF-8: {e}")); - return std::ptr::null_mut(); - } - }; - match crate::html::parse_html(s) { - Ok(doc) => Box::into_raw(Box::new(doc)), - Err(e) => { - set_last_error_structured( - &e.message, - e.location.line, - e.location.column, - XMLOXIDE_ERR_FATAL, - ); - std::ptr::null_mut() - } - } -} - -/// Parses an XML file from a filesystem path. -/// -/// Returns a pointer to the document on success, or null on failure. -/// The returned document must be freed with [`xmloxide_free_doc`]. -/// -/// # Safety -/// -/// `path` must be a valid null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_parse_file(path: *const c_char) -> *mut Document { - clear_last_error(); - if path.is_null() { - set_last_error("null path pointer"); - return std::ptr::null_mut(); - } - // SAFETY: Null check above. Caller guarantees valid null-terminated string. - let c_str = unsafe { CStr::from_ptr(path) }; - let s = match c_str.to_str() { - Ok(s) => s, - Err(e) => { - set_last_error(&format!("invalid UTF-8 in path: {e}")); - return std::ptr::null_mut(); - } - }; - match Document::parse_file(s) { - Ok(doc) => Box::into_raw(Box::new(doc)), - Err(e) => { - set_last_error_structured( - &e.message, - e.location.line, - e.location.column, - XMLOXIDE_ERR_FATAL, - ); - std::ptr::null_mut() - } - } -} - -/// Helper to convert `ErrorSeverity` to FFI severity constant. -fn severity_to_ffi(s: ErrorSeverity) -> i32 { - match s { - ErrorSeverity::Warning => XMLOXIDE_ERR_WARNING, - ErrorSeverity::Error => XMLOXIDE_ERR_ERROR, - ErrorSeverity::Fatal => XMLOXIDE_ERR_FATAL, - } -} - -/// Returns the number of parse diagnostics (warnings + recovered errors) -/// stored on a document. -/// -/// Documents parsed in recovery mode collect diagnostics during parsing. -/// Returns 0 if the document has no diagnostics or the pointer is null. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_doc_diagnostic_count(doc: *const Document) -> usize { - if doc.is_null() { - return 0; - } - let doc = unsafe { &*doc }; - doc.diagnostics.len() -} - -/// Returns the error message of the diagnostic at the given index. -/// -/// Returns null if the index is out of range. The returned string must -/// be freed with [`xmloxide_free_string`](super::strings::xmloxide_free_string). -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_doc_diagnostic_message( - doc: *const Document, - index: usize, -) -> *mut c_char { - if doc.is_null() { - return std::ptr::null_mut(); - } - let doc = unsafe { &*doc }; - match doc.diagnostics.get(index) { - Some(d) => to_c_string(&d.message), - None => std::ptr::null_mut(), - } -} - -/// Returns the line number of the diagnostic at the given index. -/// -/// Returns 0 if the index is out of range or the document pointer is null. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_doc_diagnostic_line(doc: *const Document, index: usize) -> u32 { - if doc.is_null() { - return 0; - } - let doc = unsafe { &*doc }; - doc.diagnostics.get(index).map_or(0, |d| d.location.line) -} - -/// Returns the column number of the diagnostic at the given index. -/// -/// Returns 0 if the index is out of range or the document pointer is null. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_doc_diagnostic_column(doc: *const Document, index: usize) -> u32 { - if doc.is_null() { - return 0; - } - let doc = unsafe { &*doc }; - doc.diagnostics.get(index).map_or(0, |d| d.location.column) -} - -/// Returns the severity of the diagnostic at the given index. -/// -/// Returns `XMLOXIDE_ERR_WARNING` (0), `XMLOXIDE_ERR_ERROR` (1), or -/// `XMLOXIDE_ERR_FATAL` (2). Returns -1 if out of range. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_doc_diagnostic_severity( - doc: *const Document, - index: usize, -) -> i32 { - if doc.is_null() { - return -1; - } - let doc = unsafe { &*doc }; - doc.diagnostics - .get(index) - .map_or(-1, |d| severity_to_ffi(d.severity)) -} diff --git a/browser/vendor/xmloxide/src/ffi/html5.rs b/browser/vendor/xmloxide/src/ffi/html5.rs deleted file mode 100644 index 805d9c96a..000000000 --- a/browser/vendor/xmloxide/src/ffi/html5.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! HTML5 parsing FFI functions. -#![allow(unsafe_code, clippy::missing_safety_doc)] - -use std::ffi::CStr; -use std::os::raw::c_char; - -use crate::html5::{parse_html5, parse_html5_with_options, Html5ParseOptions}; -use crate::tree::Document; - -use super::{clear_last_error, set_last_error}; - -/// Parses an HTML5 string into a document using the WHATWG parsing algorithm. -/// -/// Returns a pointer to the document on success, or null on failure. -/// The returned document must be freed with [`xmloxide_free_doc`](super::document::xmloxide_free_doc). -/// -/// # Safety -/// -/// `input` must be a valid null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_parse_html5(input: *const c_char) -> *mut Document { - clear_last_error(); - if input.is_null() { - set_last_error("null input pointer"); - return std::ptr::null_mut(); - } - // SAFETY: Null check above. Caller guarantees valid null-terminated string. - let c_str = unsafe { CStr::from_ptr(input) }; - let s = match c_str.to_str() { - Ok(s) => s, - Err(e) => { - set_last_error(&format!("invalid UTF-8: {e}")); - return std::ptr::null_mut(); - } - }; - match parse_html5(s) { - Ok(doc) => Box::into_raw(Box::new(doc)), - Err(e) => { - set_last_error(&e.to_string()); - std::ptr::null_mut() - } - } -} - -/// Parses an HTML5 fragment with the given context element. -/// -/// This implements the fragment parsing algorithm (the algorithm behind -/// `innerHTML`). The `context_element` is the tag name of the context -/// (e.g., `"body"`, `"div"`, `"table"`). -/// -/// Returns a pointer to the document on success, or null on failure. -/// The returned document must be freed with [`xmloxide_free_doc`](super::document::xmloxide_free_doc). -/// -/// # Safety -/// -/// `input` and `context_element` must be valid null-terminated UTF-8 strings. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_parse_html5_fragment( - input: *const c_char, - context_element: *const c_char, -) -> *mut Document { - clear_last_error(); - if input.is_null() { - set_last_error("null input pointer"); - return std::ptr::null_mut(); - } - if context_element.is_null() { - set_last_error("null context_element pointer"); - return std::ptr::null_mut(); - } - // SAFETY: Null checks above. Caller guarantees valid null-terminated strings. - let c_input = unsafe { CStr::from_ptr(input) }; - let s = match c_input.to_str() { - Ok(s) => s, - Err(e) => { - set_last_error(&format!("invalid UTF-8 in input: {e}")); - return std::ptr::null_mut(); - } - }; - let c_ctx = unsafe { CStr::from_ptr(context_element) }; - let ctx = match c_ctx.to_str() { - Ok(s) => s, - Err(e) => { - set_last_error(&format!("invalid UTF-8 in context_element: {e}")); - return std::ptr::null_mut(); - } - }; - let opts = Html5ParseOptions { - scripting: false, - fragment_context: Some(ctx.to_string()), - }; - match parse_html5_with_options(s, &opts) { - Ok(doc) => Box::into_raw(Box::new(doc)), - Err(e) => { - set_last_error(&e.to_string()); - std::ptr::null_mut() - } - } -} diff --git a/browser/vendor/xmloxide/src/ffi/mod.rs b/browser/vendor/xmloxide/src/ffi/mod.rs deleted file mode 100644 index b78d3da54..000000000 --- a/browser/vendor/xmloxide/src/ffi/mod.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! C FFI layer for xmloxide. -//! -//! Provides a C-compatible API for using xmloxide from C/C++ and other -//! languages that support C FFI. All symbols use the `xmloxide_` prefix. -//! -//! # Error Handling -//! -//! Functions that can fail return null pointers (for pointer types) or 0 -//! (for `NodeId` values). The last error message is stored in thread-local -//! storage and can be retrieved via [`xmloxide_last_error`]. -//! -//! # String Ownership -//! -//! All strings returned by FFI functions are caller-owned C strings that -//! must be freed via [`xmloxide_free_string`](strings::xmloxide_free_string). -//! -//! # Safety -//! -//! All `extern "C"` functions in this module are inherently unsafe because -//! they accept raw pointers from C callers. - -// FFI functions require unsafe blocks throughout. -#![allow(unsafe_code, clippy::missing_safety_doc)] - -pub mod c14n; -pub mod catalog; -pub mod css; -pub mod document; -pub mod html5; -pub mod push; -pub mod reader; -pub mod sax; -pub mod serial; -pub mod strings; -pub mod tree; -pub mod validation; -pub mod xinclude; -pub mod xpath; - -use std::cell::RefCell; -use std::ffi::CString; -use std::os::raw::c_char; - -/// Structured error stored in thread-local storage. -struct StructuredError { - message: CString, - line: u32, - column: u32, - severity: i32, // 0=warning, 1=error, 2=fatal -} - -/// Severity constants matching libxml2's `xmlErrorLevel`. -pub const XMLOXIDE_ERR_WARNING: i32 = 0; -pub const XMLOXIDE_ERR_ERROR: i32 = 1; -pub const XMLOXIDE_ERR_FATAL: i32 = 2; - -thread_local! { - static LAST_ERROR: RefCell<Option<StructuredError>> = const { RefCell::new(None) }; -} - -/// Stores an error message in thread-local storage (no location info). -fn set_last_error(msg: &str) { - LAST_ERROR.with(|cell| { - *cell.borrow_mut() = CString::new(msg).ok().map(|message| StructuredError { - message, - line: 0, - column: 0, - severity: XMLOXIDE_ERR_FATAL, - }); - }); -} - -/// Stores a structured error with location in thread-local storage. -fn set_last_error_structured(msg: &str, line: u32, column: u32, severity: i32) { - LAST_ERROR.with(|cell| { - *cell.borrow_mut() = CString::new(msg).ok().map(|message| StructuredError { - message, - line, - column, - severity, - }); - }); -} - -/// Clears the thread-local error. -fn clear_last_error() { - LAST_ERROR.with(|cell| { - *cell.borrow_mut() = None; - }); -} - -/// Returns the last error message, or null if no error occurred. -/// -/// The returned string is owned by the library and must NOT be freed -/// by the caller. It is valid until the next FFI call on the same thread. -#[no_mangle] -pub extern "C" fn xmloxide_last_error() -> *const c_char { - LAST_ERROR.with(|cell| { - let borrow = cell.borrow(); - match borrow.as_ref() { - Some(e) => e.message.as_ptr(), - None => std::ptr::null(), - } - }) -} - -/// Returns the line number of the last error, or 0 if unknown. -#[no_mangle] -pub extern "C" fn xmloxide_last_error_line() -> u32 { - LAST_ERROR.with(|cell| { - let borrow = cell.borrow(); - borrow.as_ref().map_or(0, |e| e.line) - }) -} - -/// Returns the column number of the last error, or 0 if unknown. -#[no_mangle] -pub extern "C" fn xmloxide_last_error_column() -> u32 { - LAST_ERROR.with(|cell| { - let borrow = cell.borrow(); - borrow.as_ref().map_or(0, |e| e.column) - }) -} - -/// Returns the severity of the last error. -/// -/// Returns `XMLOXIDE_ERR_WARNING` (0), `XMLOXIDE_ERR_ERROR` (1), or -/// `XMLOXIDE_ERR_FATAL` (2). Returns -1 if no error occurred. -#[no_mangle] -pub extern "C" fn xmloxide_last_error_severity() -> i32 { - LAST_ERROR.with(|cell| { - let borrow = cell.borrow(); - borrow.as_ref().map_or(-1, |e| e.severity) - }) -} diff --git a/browser/vendor/xmloxide/src/ffi/push.rs b/browser/vendor/xmloxide/src/ffi/push.rs deleted file mode 100644 index ceeaa24e0..000000000 --- a/browser/vendor/xmloxide/src/ffi/push.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! FFI wrappers for the push/incremental parser. -#![allow(unsafe_code, clippy::missing_safety_doc)] - -use crate::ffi::{clear_last_error, set_last_error}; -use crate::parser::PushParser; -use crate::tree::Document; - -/// Creates a new push parser with default options. -/// -/// Returns a pointer to the parser, or null on failure. -/// The parser must be freed with [`xmloxide_push_parser_free`] or consumed -/// by [`xmloxide_push_parser_finish`]. -#[no_mangle] -pub extern "C" fn xmloxide_push_parser_new() -> *mut PushParser { - clear_last_error(); - Box::into_raw(Box::new(PushParser::new())) -} - -/// Feeds a chunk of raw bytes into the push parser. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_push_parser_push( - parser: *mut PushParser, - data: *const u8, - len: usize, -) { - if parser.is_null() || data.is_null() { - return; - } - let parser = &mut *parser; - let slice = std::slice::from_raw_parts(data, len); - parser.push(slice); -} - -/// Finalizes parsing and returns the constructed document. -/// -/// This **consumes** the parser — the parser pointer becomes invalid after -/// this call and must not be used again. Do NOT call `xmloxide_push_parser_free` -/// on a parser that has been finished. -/// -/// Returns a document pointer on success, or null on failure. -/// The returned document must be freed with `xmloxide_free_doc`. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_push_parser_finish(parser: *mut PushParser) -> *mut Document { - if parser.is_null() { - set_last_error("null parser pointer"); - return std::ptr::null_mut(); - } - clear_last_error(); - let parser = *Box::from_raw(parser); - match parser.finish() { - Ok(doc) => Box::into_raw(Box::new(doc)), - Err(e) => { - set_last_error(&e.to_string()); - std::ptr::null_mut() - } - } -} - -/// Returns the number of bytes currently buffered in the push parser. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_push_parser_buffered_bytes(parser: *const PushParser) -> usize { - if parser.is_null() { - return 0; - } - (*parser).buffered_bytes() -} - -/// Resets the push parser, discarding all buffered data. -/// -/// After this call the parser is in the same state as a newly created one -/// and can be reused for another document. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_push_parser_reset(parser: *mut PushParser) { - if parser.is_null() { - return; - } - (*parser).reset(); -} - -/// Frees a push parser without finishing it. -/// -/// Use this to discard a parser whose data you no longer need. -/// Passing null is safe and does nothing. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_push_parser_free(parser: *mut PushParser) { - if parser.is_null() { - return; - } - drop(Box::from_raw(parser)); -} diff --git a/browser/vendor/xmloxide/src/ffi/reader.rs b/browser/vendor/xmloxide/src/ffi/reader.rs deleted file mode 100644 index 1dd15c045..000000000 --- a/browser/vendor/xmloxide/src/ffi/reader.rs +++ /dev/null @@ -1,317 +0,0 @@ -//! FFI wrappers for the `XmlReader` pull-based streaming API. -#![allow(unsafe_code, clippy::missing_safety_doc)] - -use std::ffi::{CStr, CString}; -use std::os::raw::c_char; - -use crate::ffi::{clear_last_error, set_last_error}; -use crate::reader::{XmlNodeType, XmlReader}; - -/// FFI-safe reader that owns the input string. -/// -/// The Rust `XmlReader<'a>` borrows its input, but C callers need an -/// opaque handle that owns everything. We heap-allocate the input -/// string and create a reader that borrows from it with an erased -/// lifetime. This is safe because the string is never moved or -/// reallocated while the reader exists. -/// Opaque reader handle for FFI consumers. -/// -/// This struct has no public fields — C callers interact with it -/// exclusively through the `xmloxide_reader_*` functions. -pub struct FfiReader { - /// The owned input string, heap-allocated and never moved. - /// Must be declared before `reader` so it outlives it during drop. - _input: Box<str>, - /// The reader. Its lifetime is tied to `_input` but erased to `'static`. - reader: XmlReader<'static>, -} - -impl FfiReader { - fn new(input: String) -> Self { - let boxed: Box<str> = input.into_boxed_str(); - // SAFETY: We extend the borrow's lifetime to 'static. This is safe - // because `_input` is heap-allocated, never moved or reallocated, - // and outlives `reader` (fields are dropped in declaration order, - // so `reader` is dropped before `_input`). - let reader = unsafe { - let static_ref: &'static str = &*(std::ptr::from_ref::<str>(&boxed)); - XmlReader::new(static_ref) - }; - Self { - _input: boxed, - reader, - } - } -} - -// --- XmlReader node type constants matching libxml2's xmlReaderTypes --- - -/// No node (reader not yet advanced). -pub const XMLOXIDE_READER_NONE: i32 = 0; -/// Element start tag. -pub const XMLOXIDE_READER_ELEMENT: i32 = 1; -/// Attribute. -pub const XMLOXIDE_READER_ATTRIBUTE: i32 = 2; -/// Text node. -pub const XMLOXIDE_READER_TEXT: i32 = 3; -/// CDATA section. -pub const XMLOXIDE_READER_CDATA: i32 = 4; -/// Processing instruction. -pub const XMLOXIDE_READER_PI: i32 = 7; -/// XML comment. -pub const XMLOXIDE_READER_COMMENT: i32 = 8; -/// Document type declaration. -pub const XMLOXIDE_READER_DOCUMENT_TYPE: i32 = 10; -/// Whitespace-only text. -pub const XMLOXIDE_READER_WHITESPACE: i32 = 13; -/// Element end tag. -pub const XMLOXIDE_READER_END_ELEMENT: i32 = 15; -/// XML declaration. -pub const XMLOXIDE_READER_XML_DECLARATION: i32 = 17; -/// End of document. -pub const XMLOXIDE_READER_END_DOCUMENT: i32 = -1; - -fn node_type_to_int(nt: XmlNodeType) -> i32 { - match nt { - XmlNodeType::None => XMLOXIDE_READER_NONE, - XmlNodeType::Element => XMLOXIDE_READER_ELEMENT, - XmlNodeType::EndElement => XMLOXIDE_READER_END_ELEMENT, - XmlNodeType::Text => XMLOXIDE_READER_TEXT, - XmlNodeType::CData => XMLOXIDE_READER_CDATA, - XmlNodeType::Comment => XMLOXIDE_READER_COMMENT, - XmlNodeType::ProcessingInstruction => XMLOXIDE_READER_PI, - XmlNodeType::XmlDeclaration => XMLOXIDE_READER_XML_DECLARATION, - XmlNodeType::DocumentType => XMLOXIDE_READER_DOCUMENT_TYPE, - XmlNodeType::Whitespace => XMLOXIDE_READER_WHITESPACE, - XmlNodeType::Attribute => XMLOXIDE_READER_ATTRIBUTE, - XmlNodeType::EndDocument => XMLOXIDE_READER_END_DOCUMENT, - } -} - -fn to_c_string(s: &str) -> *mut c_char { - match CString::new(s) { - Ok(cs) => cs.into_raw(), - Err(_) => std::ptr::null_mut(), - } -} - -/// Creates a new `XmlReader` from a null-terminated UTF-8 string. -/// -/// Returns an opaque reader pointer, or null on failure. -/// The reader must be freed with [`xmloxide_reader_free`]. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_reader_new(input: *const c_char) -> *mut FfiReader { - if input.is_null() { - set_last_error("null input pointer"); - return std::ptr::null_mut(); - } - clear_last_error(); - let c_str = CStr::from_ptr(input); - let Ok(s) = c_str.to_str() else { - set_last_error("input is not valid UTF-8"); - return std::ptr::null_mut(); - }; - Box::into_raw(Box::new(FfiReader::new(s.to_string()))) -} - -/// Advances the reader to the next node. -/// -/// Returns 1 if the reader advanced to a node, 0 if the document ended, -/// or -1 on error. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_reader_read(reader: *mut FfiReader) -> i32 { - if reader.is_null() { - return -1; - } - match (*reader).reader.read() { - Ok(true) => 1, - Ok(false) => 0, - Err(e) => { - set_last_error(&e.to_string()); - -1 - } - } -} - -/// Returns the node type of the current node. -/// -/// Returns one of the `XMLOXIDE_READER_*` constants. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_reader_node_type(reader: *const FfiReader) -> i32 { - if reader.is_null() { - return XMLOXIDE_READER_NONE; - } - node_type_to_int((*reader).reader.node_type()) -} - -/// Returns the name of the current node, or null. -/// -/// The returned string must be freed with `xmloxide_free_string`. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_reader_name(reader: *const FfiReader) -> *mut c_char { - if reader.is_null() { - return std::ptr::null_mut(); - } - match (*reader).reader.name() { - Some(name) => to_c_string(name), - None => std::ptr::null_mut(), - } -} - -/// Returns the local name of the current node (without prefix), or null. -/// -/// The returned string must be freed with `xmloxide_free_string`. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_reader_local_name(reader: *const FfiReader) -> *mut c_char { - if reader.is_null() { - return std::ptr::null_mut(); - } - match (*reader).reader.local_name() { - Some(name) => to_c_string(name), - None => std::ptr::null_mut(), - } -} - -/// Returns the namespace prefix of the current node, or null. -/// -/// The returned string must be freed with `xmloxide_free_string`. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_reader_prefix(reader: *const FfiReader) -> *mut c_char { - if reader.is_null() { - return std::ptr::null_mut(); - } - match (*reader).reader.prefix() { - Some(p) => to_c_string(p), - None => std::ptr::null_mut(), - } -} - -/// Returns the namespace URI of the current node, or null. -/// -/// The returned string must be freed with `xmloxide_free_string`. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_reader_namespace_uri(reader: *const FfiReader) -> *mut c_char { - if reader.is_null() { - return std::ptr::null_mut(); - } - match (*reader).reader.namespace_uri() { - Some(ns) => to_c_string(ns), - None => std::ptr::null_mut(), - } -} - -/// Returns the value of the current node (text content, comment, etc.), or null. -/// -/// The returned string must be freed with `xmloxide_free_string`. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_reader_value(reader: *const FfiReader) -> *mut c_char { - if reader.is_null() { - return std::ptr::null_mut(); - } - match (*reader).reader.value() { - Some(v) => to_c_string(v), - None => std::ptr::null_mut(), - } -} - -/// Returns the depth of the current node in the document tree. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_reader_depth(reader: *const FfiReader) -> u32 { - if reader.is_null() { - return 0; - } - (*reader).reader.depth() -} - -/// Returns whether the current element is a self-closing (empty) element. -/// -/// Returns 1 for empty elements, 0 otherwise. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_reader_is_empty_element(reader: *const FfiReader) -> i32 { - if reader.is_null() { - return 0; - } - i32::from((*reader).reader.is_empty_element()) -} - -/// Returns whether the current node has a value. -/// -/// Returns 1 if it has a value, 0 otherwise. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_reader_has_value(reader: *const FfiReader) -> i32 { - if reader.is_null() { - return 0; - } - i32::from((*reader).reader.has_value()) -} - -/// Returns the number of attributes on the current element. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_reader_attribute_count(reader: *const FfiReader) -> usize { - if reader.is_null() { - return 0; - } - (*reader).reader.attribute_count() -} - -/// Returns the value of an attribute by name on the current element, or null. -/// -/// The returned string must be freed with `xmloxide_free_string`. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_reader_get_attribute( - reader: *const FfiReader, - name: *const c_char, -) -> *mut c_char { - if reader.is_null() || name.is_null() { - return std::ptr::null_mut(); - } - let Ok(name) = CStr::from_ptr(name).to_str() else { - return std::ptr::null_mut(); - }; - match (*reader).reader.get_attribute(name) { - Some(v) => to_c_string(v), - None => std::ptr::null_mut(), - } -} - -/// Moves the reader to the first attribute of the current element. -/// -/// Returns 1 if successful, 0 if no attributes or not on an element. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_reader_move_to_first_attribute(reader: *mut FfiReader) -> i32 { - if reader.is_null() { - return 0; - } - i32::from((*reader).reader.move_to_first_attribute()) -} - -/// Moves the reader to the next attribute of the current element. -/// -/// Returns 1 if successful, 0 if no more attributes. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_reader_move_to_next_attribute(reader: *mut FfiReader) -> i32 { - if reader.is_null() { - return 0; - } - i32::from((*reader).reader.move_to_next_attribute()) -} - -/// Moves the reader back to the element from an attribute. -/// -/// Returns 1 if the reader was moved back, 0 if not on an attribute. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_reader_move_to_element(reader: *mut FfiReader) -> i32 { - if reader.is_null() { - return 0; - } - i32::from((*reader).reader.move_to_element()) -} - -/// Frees a reader. Passing null is safe and does nothing. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_reader_free(reader: *mut FfiReader) { - if reader.is_null() { - return; - } - drop(Box::from_raw(reader)); -} diff --git a/browser/vendor/xmloxide/src/ffi/sax.rs b/browser/vendor/xmloxide/src/ffi/sax.rs deleted file mode 100644 index cb0eaae42..000000000 --- a/browser/vendor/xmloxide/src/ffi/sax.rs +++ /dev/null @@ -1,193 +0,0 @@ -//! FFI wrappers for the SAX2 streaming parser. -#![allow(unsafe_code, clippy::missing_safety_doc)] - -use std::ffi::{CStr, CString}; -use std::os::raw::c_char; - -use crate::ffi::{clear_last_error, set_last_error}; -use crate::parser::ParseOptions; -use crate::sax::{self, SaxHandler}; - -/// C function pointer type for `start_element` events. -/// -/// Arguments: `local_name`, `prefix` (may be null), `namespace` (may be null), -/// `attr_names` array, `attr_values` array, `attr_count`, `user_data`. -pub type StartElementCb = Option< - unsafe extern "C" fn( - *const c_char, - *const c_char, - *const c_char, - *const *const c_char, - *const *const c_char, - usize, - *mut std::ffi::c_void, - ), ->; - -/// C function pointer type for `end_element` events. -/// -/// Arguments: `local_name`, `prefix` (may be null), `namespace` (may be null), -/// `user_data`. -pub type EndElementCb = Option< - unsafe extern "C" fn(*const c_char, *const c_char, *const c_char, *mut std::ffi::c_void), ->; - -/// C function pointer type for `characters` / `cdata` / `comment` events. -/// -/// Arguments: `content`, `user_data`. -pub type TextCb = Option<unsafe extern "C" fn(*const c_char, *mut std::ffi::c_void)>; - -/// C function pointer type for `processing_instruction` events. -/// -/// Arguments: `target`, `data` (may be null), `user_data`. -pub type PiCb = Option<unsafe extern "C" fn(*const c_char, *const c_char, *mut std::ffi::c_void)>; - -/// A SAX handler specified as C function pointers. -/// -/// Set any callback to `NULL` to ignore that event type. -/// `user_data` is passed through to every callback. -#[repr(C)] -pub struct XmloxideSaxHandler { - pub start_element: StartElementCb, - pub end_element: EndElementCb, - pub characters: TextCb, - pub cdata: TextCb, - pub comment: TextCb, - pub processing_instruction: PiCb, - pub user_data: *mut std::ffi::c_void, -} - -/// Bridge that implements the Rust `SaxHandler` trait by forwarding events -/// to C function pointers. -struct FfiSaxBridge { - handler: *const XmloxideSaxHandler, -} - -impl SaxHandler for FfiSaxBridge { - fn start_element( - &mut self, - local_name: &str, - prefix: Option<&str>, - namespace: Option<&str>, - attributes: &[(String, String, Option<String>, Option<String>)], - ) { - // SAFETY: handler pointer validity is the caller's responsibility. - let h = unsafe { &*self.handler }; - let Some(cb) = h.start_element else { return }; - - let c_local = CString::new(local_name).unwrap_or_default(); - let c_prefix = prefix.and_then(|s| CString::new(s).ok()); - let c_ns = namespace.and_then(|s| CString::new(s).ok()); - - // Build parallel arrays of attribute names and values. - let c_names: Vec<CString> = attributes - .iter() - .filter_map(|(name, _, _, _)| CString::new(name.as_str()).ok()) - .collect(); - let c_values: Vec<CString> = attributes - .iter() - .filter_map(|(_, value, _, _)| CString::new(value.as_str()).ok()) - .collect(); - let name_ptrs: Vec<*const c_char> = c_names.iter().map(|s| s.as_ptr()).collect(); - let value_ptrs: Vec<*const c_char> = c_values.iter().map(|s| s.as_ptr()).collect(); - - unsafe { - cb( - c_local.as_ptr(), - c_prefix.as_ref().map_or(std::ptr::null(), |s| s.as_ptr()), - c_ns.as_ref().map_or(std::ptr::null(), |s| s.as_ptr()), - name_ptrs.as_ptr(), - value_ptrs.as_ptr(), - c_names.len(), - h.user_data, - ); - } - } - - fn end_element(&mut self, local_name: &str, prefix: Option<&str>, namespace: Option<&str>) { - let h = unsafe { &*self.handler }; - let Some(cb) = h.end_element else { return }; - - let c_local = CString::new(local_name).unwrap_or_default(); - let c_prefix = prefix.and_then(|s| CString::new(s).ok()); - let c_ns = namespace.and_then(|s| CString::new(s).ok()); - - unsafe { - cb( - c_local.as_ptr(), - c_prefix.as_ref().map_or(std::ptr::null(), |s| s.as_ptr()), - c_ns.as_ref().map_or(std::ptr::null(), |s| s.as_ptr()), - h.user_data, - ); - } - } - - fn characters(&mut self, content: &str) { - let h = unsafe { &*self.handler }; - let Some(cb) = h.characters else { return }; - let c_content = CString::new(content).unwrap_or_default(); - unsafe { cb(c_content.as_ptr(), h.user_data) }; - } - - fn cdata(&mut self, content: &str) { - let h = unsafe { &*self.handler }; - let Some(cb) = h.cdata else { return }; - let c_content = CString::new(content).unwrap_or_default(); - unsafe { cb(c_content.as_ptr(), h.user_data) }; - } - - fn comment(&mut self, content: &str) { - let h = unsafe { &*self.handler }; - let Some(cb) = h.comment else { return }; - let c_content = CString::new(content).unwrap_or_default(); - unsafe { cb(c_content.as_ptr(), h.user_data) }; - } - - fn processing_instruction(&mut self, target: &str, data: Option<&str>) { - let h = unsafe { &*self.handler }; - let Some(cb) = h.processing_instruction else { - return; - }; - let c_target = CString::new(target).unwrap_or_default(); - let c_data = data.and_then(|s| CString::new(s).ok()); - unsafe { - cb( - c_target.as_ptr(), - c_data.as_ref().map_or(std::ptr::null(), |s| s.as_ptr()), - h.user_data, - ); - } - } -} - -/// Parses XML with SAX streaming, dispatching events to C function pointers. -/// -/// `xml` must be a valid null-terminated UTF-8 C string. -/// `handler` must point to a valid `XmloxideSaxHandler` struct. -/// -/// Returns 0 on success, -1 on error. Use `xmloxide_last_error()` for details. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_sax_parse( - xml: *const c_char, - handler: *const XmloxideSaxHandler, -) -> i32 { - if xml.is_null() || handler.is_null() { - set_last_error("null argument"); - return -1; - } - clear_last_error(); - - let Ok(input) = CStr::from_ptr(xml).to_str() else { - set_last_error("invalid UTF-8 in input"); - return -1; - }; - - let mut bridge = FfiSaxBridge { handler }; - match sax::parse_sax(input, &ParseOptions::default(), &mut bridge) { - Ok(()) => 0, - Err(e) => { - set_last_error(&e.to_string()); - -1 - } - } -} diff --git a/browser/vendor/xmloxide/src/ffi/serial.rs b/browser/vendor/xmloxide/src/ffi/serial.rs deleted file mode 100644 index 1642e56ce..000000000 --- a/browser/vendor/xmloxide/src/ffi/serial.rs +++ /dev/null @@ -1,136 +0,0 @@ -//! Serialization FFI functions. -#![allow(unsafe_code, clippy::missing_safety_doc)] - -use std::ffi::CStr; -use std::os::raw::c_char; - -use crate::serial::SerializeOptions; -use crate::tree::Document; - -use super::strings::to_c_string; -use super::{clear_last_error, set_last_error}; - -/// Serializes a document to an XML string. -/// -/// Returns a caller-owned C string that must be freed with -/// `xmloxide_free_string`. Returns null on failure. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_serialize(doc: *const Document) -> *mut c_char { - clear_last_error(); - if doc.is_null() { - set_last_error("null document pointer"); - return std::ptr::null_mut(); - } - // SAFETY: Null check above. Caller guarantees `doc` is a valid pointer from a parse function. - let doc = unsafe { &*doc }; - let output = crate::serial::serialize(doc); - to_c_string(&output) -} - -/// Serializes a document to a pretty-printed XML string. -/// -/// Uses the default two-space indentation. Returns a caller-owned C string -/// that must be freed with `xmloxide_free_string`. Returns null on failure. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_serialize_pretty(doc: *const Document) -> *mut c_char { - clear_last_error(); - if doc.is_null() { - set_last_error("null document pointer"); - return std::ptr::null_mut(); - } - // SAFETY: Null check above. Caller guarantees `doc` is a valid pointer from a parse function. - let doc = unsafe { &*doc }; - let opts = SerializeOptions::default().indent(true); - let output = crate::serial::serialize_with_options(doc, &opts); - to_c_string(&output) -} - -/// Serializes a document to a pretty-printed XML string with a custom indent. -/// -/// `indent_str` is the string used for each indentation level (e.g., `"\t"` -/// or `" "`). Returns a caller-owned C string that must be freed with -/// `xmloxide_free_string`. Returns null on failure. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. `indent_str` must be a valid -/// null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_serialize_pretty_custom( - doc: *const Document, - indent_str: *const c_char, -) -> *mut c_char { - clear_last_error(); - if doc.is_null() { - set_last_error("null document pointer"); - return std::ptr::null_mut(); - } - if indent_str.is_null() { - set_last_error("null indent_str pointer"); - return std::ptr::null_mut(); - } - // SAFETY: Null checks above. Caller guarantees valid pointers. - let doc = unsafe { &*doc }; - let c_indent = unsafe { CStr::from_ptr(indent_str) }; - let Ok(indent) = c_indent.to_str() else { - set_last_error("invalid UTF-8 in indent_str"); - return std::ptr::null_mut(); - }; - let opts = SerializeOptions::default().indent(true).indent_str(indent); - let output = crate::serial::serialize_with_options(doc, &opts); - to_c_string(&output) -} - -/// Serializes a document to an HTML string. -/// -/// Returns a caller-owned C string that must be freed with -/// `xmloxide_free_string`. Returns null on failure. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_serialize_html(doc: *const Document) -> *mut c_char { - clear_last_error(); - if doc.is_null() { - set_last_error("null document pointer"); - return std::ptr::null_mut(); - } - // SAFETY: Null check above. Caller guarantees `doc` is a valid pointer from a parse function. - let doc = unsafe { &*doc }; - let output = crate::serial::html::serialize_html(doc); - to_c_string(&output) -} - -/// Serializes a document to an HTML5 string. -/// -/// Uses the WHATWG HTML serialization algorithm: void elements are not -/// self-closed, raw text elements (`<script>`, `<style>`) are not escaped, -/// and foreign content (`SVG`/`MathML`) uses self-closing tags when empty. -/// -/// Returns a caller-owned C string that must be freed with -/// `xmloxide_free_string`. Returns null on failure. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_serialize_html5(doc: *const Document) -> *mut c_char { - clear_last_error(); - if doc.is_null() { - set_last_error("null document pointer"); - return std::ptr::null_mut(); - } - // SAFETY: Null check above. Caller guarantees `doc` is a valid pointer from a parse function. - let doc = unsafe { &*doc }; - let output = crate::serial::html::serialize_html5(doc); - to_c_string(&output) -} diff --git a/browser/vendor/xmloxide/src/ffi/strings.rs b/browser/vendor/xmloxide/src/ffi/strings.rs deleted file mode 100644 index bd83c0b65..000000000 --- a/browser/vendor/xmloxide/src/ffi/strings.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! String lifecycle helpers for the FFI layer. -#![allow(unsafe_code)] - -use std::ffi::CString; -use std::os::raw::c_char; - -/// Converts a Rust `&str` to a caller-owned C string. -/// -/// Returns null if the string contains interior null bytes. -pub(crate) fn to_c_string(s: &str) -> *mut c_char { - match CString::new(s) { - Ok(cs) => cs.into_raw(), - Err(_) => std::ptr::null_mut(), - } -} - -/// Frees a string previously returned by an xmloxide FFI function. -/// -/// Passing null is safe and does nothing. -/// -/// # Safety -/// -/// The pointer must have been returned by an xmloxide FFI function, -/// or be null. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_free_string(ptr: *mut c_char) { - if !ptr.is_null() { - // SAFETY: `ptr` was created by `CString::into_raw` via `to_c_string`, and is non-null. - unsafe { - drop(CString::from_raw(ptr)); - } - } -} diff --git a/browser/vendor/xmloxide/src/ffi/tree.rs b/browser/vendor/xmloxide/src/ffi/tree.rs deleted file mode 100644 index ddba12044..000000000 --- a/browser/vendor/xmloxide/src/ffi/tree.rs +++ /dev/null @@ -1,734 +0,0 @@ -//! Tree navigation and node inspection FFI functions. -#![allow(unsafe_code, clippy::missing_safety_doc)] - -use std::os::raw::c_char; - -use crate::tree::{Document, NodeId, NodeKind}; - -use super::strings::to_c_string; - -// Node type constants matching common XML conventions. - -/// Element node type constant. -pub const XMLOXIDE_NODE_ELEMENT: i32 = 1; -/// Text node type constant. -pub const XMLOXIDE_NODE_TEXT: i32 = 3; -/// CDATA section node type constant. -pub const XMLOXIDE_NODE_CDATA: i32 = 4; -/// Entity reference node type constant. -pub const XMLOXIDE_NODE_ENTITY_REF: i32 = 5; -/// Processing instruction node type constant. -pub const XMLOXIDE_NODE_PI: i32 = 7; -/// Comment node type constant. -pub const XMLOXIDE_NODE_COMMENT: i32 = 8; -/// Document node type constant. -pub const XMLOXIDE_NODE_DOCUMENT: i32 = 9; -/// Document type node type constant. -pub const XMLOXIDE_NODE_DOCUMENT_TYPE: i32 = 10; - -/// Helper to convert `Option<NodeId>` to a raw u32 (0 = no node). -fn node_id_to_raw(id: Option<NodeId>) -> u32 { - id.map_or(0, NodeId::into_raw) -} - -/// Helper to safely dereference a document pointer and node id. -/// -/// Returns `None` if either the document is null or the raw node id is 0. -unsafe fn doc_and_node(doc: *const Document, raw_node: u32) -> Option<(&'static Document, NodeId)> { - if doc.is_null() { - return None; - } - // SAFETY: Null check above. Caller guarantees `doc` is a valid pointer from a parse function. - let doc = unsafe { &*doc }; - let node_id = NodeId::from_raw(raw_node)?; - Some((doc, node_id)) -} - -/// Returns the document root node id. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_doc_root(doc: *const Document) -> u32 { - if doc.is_null() { - return 0; - } - // SAFETY: Null check above. Caller guarantees `doc` is a valid pointer from a parse function. - let doc = unsafe { &*doc }; - doc.root().into_raw() -} - -/// Returns the root element of the document, or 0 if none. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_doc_root_element(doc: *const Document) -> u32 { - if doc.is_null() { - return 0; - } - // SAFETY: Null check above. Caller guarantees `doc` is a valid pointer from a parse function. - let doc = unsafe { &*doc }; - node_id_to_raw(doc.root_element()) -} - -/// Returns the parent of a node, or 0 if none. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_node_parent(doc: *const Document, node: u32) -> u32 { - let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { - return 0; - }; - node_id_to_raw(doc.parent(node_id)) -} - -/// Returns the first child of a node, or 0 if none. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_node_first_child(doc: *const Document, node: u32) -> u32 { - let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { - return 0; - }; - node_id_to_raw(doc.first_child(node_id)) -} - -/// Returns the last child of a node, or 0 if none. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_node_last_child(doc: *const Document, node: u32) -> u32 { - let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { - return 0; - }; - node_id_to_raw(doc.last_child(node_id)) -} - -/// Returns the next sibling of a node, or 0 if none. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_node_next_sibling(doc: *const Document, node: u32) -> u32 { - let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { - return 0; - }; - node_id_to_raw(doc.next_sibling(node_id)) -} - -/// Returns the previous sibling of a node, or 0 if none. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_node_prev_sibling(doc: *const Document, node: u32) -> u32 { - let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { - return 0; - }; - node_id_to_raw(doc.prev_sibling(node_id)) -} - -/// Returns the node type as an integer constant. -/// -/// Returns -1 if the document or node is invalid. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_node_type(doc: *const Document, node: u32) -> i32 { - let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { - return -1; - }; - match &doc.node(node_id).kind { - NodeKind::Document => XMLOXIDE_NODE_DOCUMENT, - NodeKind::Element { .. } => XMLOXIDE_NODE_ELEMENT, - NodeKind::Text { .. } => XMLOXIDE_NODE_TEXT, - NodeKind::CData { .. } => XMLOXIDE_NODE_CDATA, - NodeKind::Comment { .. } => XMLOXIDE_NODE_COMMENT, - NodeKind::ProcessingInstruction { .. } => XMLOXIDE_NODE_PI, - NodeKind::EntityRef { .. } => XMLOXIDE_NODE_ENTITY_REF, - NodeKind::DocumentType { .. } => XMLOXIDE_NODE_DOCUMENT_TYPE, - } -} - -/// Returns the name of a node (element local name or PI target). -/// -/// Returns null for node types that have no name. The returned string -/// must be freed with `xmloxide_free_string`. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_node_name(doc: *const Document, node: u32) -> *mut c_char { - let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { - return std::ptr::null_mut(); - }; - match doc.node_name(node_id) { - Some(name) => to_c_string(name), - None => std::ptr::null_mut(), - } -} - -/// Returns the direct text content of a text, comment, CDATA, or PI node. -/// -/// Returns null for element and document nodes. The returned string -/// must be freed with `xmloxide_free_string`. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_node_text(doc: *const Document, node: u32) -> *mut c_char { - let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { - return std::ptr::null_mut(); - }; - match doc.node_text(node_id) { - Some(text) => to_c_string(text), - None => std::ptr::null_mut(), - } -} - -/// Returns the concatenated text content of a node and all descendants. -/// -/// The returned string must be freed with `xmloxide_free_string`. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_node_text_content( - doc: *const Document, - node: u32, -) -> *mut c_char { - let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { - return std::ptr::null_mut(); - }; - let text = doc.text_content(node_id); - to_c_string(&text) -} - -/// Returns the namespace URI of an element node, or null if none. -/// -/// The returned string must be freed with `xmloxide_free_string`. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_node_namespace(doc: *const Document, node: u32) -> *mut c_char { - let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { - return std::ptr::null_mut(); - }; - match doc.node_namespace(node_id) { - Some(ns) => to_c_string(ns), - None => std::ptr::null_mut(), - } -} - -/// Returns the value of an attribute by name on an element node. -/// -/// Returns null if the attribute is not present. The returned string -/// must be freed with `xmloxide_free_string`. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. `name` must be a valid -/// null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_node_attribute( - doc: *const Document, - node: u32, - name: *const c_char, -) -> *mut c_char { - let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { - return std::ptr::null_mut(); - }; - if name.is_null() { - return std::ptr::null_mut(); - } - // SAFETY: Null check above. Caller guarantees `name` is a valid null-terminated string. - let c_name = unsafe { std::ffi::CStr::from_ptr(name) }; - let Ok(name_str) = c_name.to_str() else { - return std::ptr::null_mut(); - }; - match doc.attribute(node_id, name_str) { - Some(val) => to_c_string(val), - None => std::ptr::null_mut(), - } -} - -/// Returns the number of attributes on an element node. -/// -/// Returns 0 for non-element nodes. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_node_attribute_count(doc: *const Document, node: u32) -> usize { - let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { - return 0; - }; - doc.attributes(node_id).len() -} - -/// Returns the name of the attribute at the given index. -/// -/// Returns null if the index is out of range. The returned string must -/// be freed with `xmloxide_free_string`. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_node_attribute_name_at( - doc: *const Document, - node: u32, - index: usize, -) -> *mut c_char { - let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { - return std::ptr::null_mut(); - }; - let attrs = doc.attributes(node_id); - match attrs.get(index) { - Some(attr) => to_c_string(&attr.name), - None => std::ptr::null_mut(), - } -} - -/// Returns the value of the attribute at the given index. -/// -/// Returns null if the index is out of range. The returned string must -/// be freed with `xmloxide_free_string`. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_node_attribute_value_at( - doc: *const Document, - node: u32, - index: usize, -) -> *mut c_char { - let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { - return std::ptr::null_mut(); - }; - let attrs = doc.attributes(node_id); - match attrs.get(index) { - Some(attr) => to_c_string(&attr.value), - None => std::ptr::null_mut(), - } -} - -/// Helper to safely dereference a mutable document pointer and node id. -unsafe fn doc_and_node_mut( - doc: *mut Document, - raw_node: u32, -) -> Option<(&'static mut Document, NodeId)> { - if doc.is_null() { - return None; - } - // SAFETY: Null check above. Caller guarantees `doc` is a valid pointer from a parse function. - let doc = unsafe { &mut *doc }; - let node_id = NodeId::from_raw(raw_node)?; - Some((doc, node_id)) -} - -/// Creates a new element node and returns its id (0 on failure). -/// -/// The node is detached — use `xmloxide_append_child` to add it to the tree. -/// The returned node is owned by the document and freed when the document is freed. -/// -/// # Safety -/// -/// `doc` must be a valid mutable document pointer. `name` must be a valid -/// null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_create_element(doc: *mut Document, name: *const c_char) -> u32 { - if doc.is_null() || name.is_null() { - return 0; - } - // SAFETY: Null checks above. Caller guarantees valid pointers. - let doc = unsafe { &mut *doc }; - let c_name = unsafe { std::ffi::CStr::from_ptr(name) }; - let Ok(name_str) = c_name.to_str() else { - return 0; - }; - let node = doc.create_node(NodeKind::Element { - name: name_str.to_string(), - prefix: None, - namespace: None, - attributes: vec![], - }); - node.into_raw() -} - -/// Creates a new text node and returns its id (0 on failure). -/// -/// # Safety -/// -/// `doc` must be a valid mutable document pointer. `content` must be a valid -/// null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_create_text(doc: *mut Document, content: *const c_char) -> u32 { - if doc.is_null() || content.is_null() { - return 0; - } - // SAFETY: Null checks above. - let doc = unsafe { &mut *doc }; - let c_content = unsafe { std::ffi::CStr::from_ptr(content) }; - let Ok(text) = c_content.to_str() else { - return 0; - }; - let node = doc.create_node(NodeKind::Text { - content: text.to_string(), - }); - node.into_raw() -} - -/// Creates a new comment node and returns its id (0 on failure). -/// -/// # Safety -/// -/// `doc` must be a valid mutable document pointer. `content` must be a valid -/// null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_create_comment( - doc: *mut Document, - content: *const c_char, -) -> u32 { - if doc.is_null() || content.is_null() { - return 0; - } - // SAFETY: Null checks above. - let doc = unsafe { &mut *doc }; - let c_content = unsafe { std::ffi::CStr::from_ptr(content) }; - let Ok(text) = c_content.to_str() else { - return 0; - }; - let node = doc.create_node(NodeKind::Comment { - content: text.to_string(), - }); - node.into_raw() -} - -/// Appends a child node to a parent. Returns 1 on success, 0 on failure. -/// -/// # Safety -/// -/// `doc` must be a valid mutable document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_append_child(doc: *mut Document, parent: u32, child: u32) -> i32 { - let Some((doc, parent_id)) = (unsafe { doc_and_node_mut(doc, parent) }) else { - return 0; - }; - let Some(child_id) = NodeId::from_raw(child) else { - return 0; - }; - doc.append_child(parent_id, child_id); - 1 -} - -/// Removes a node from the tree. Returns 1 on success, 0 on failure. -/// -/// The node remains in the arena but is detached from the tree. -/// -/// # Safety -/// -/// `doc` must be a valid mutable document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_remove_node(doc: *mut Document, node: u32) -> i32 { - let Some((doc, node_id)) = (unsafe { doc_and_node_mut(doc, node) }) else { - return 0; - }; - doc.remove_node(node_id); - 1 -} - -/// Deep-clones a node and its descendants. Returns the new node id (0 on failure). -/// -/// # Safety -/// -/// `doc` must be a valid mutable document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_clone_node(doc: *mut Document, node: u32, deep: i32) -> u32 { - let Some((doc, node_id)) = (unsafe { doc_and_node_mut(doc, node) }) else { - return 0; - }; - let cloned = doc.clone_node(node_id, deep != 0); - cloned.into_raw() -} - -/// Sets the text content of a node. Returns 1 on success, 0 on failure. -/// -/// For text, CDATA, and comment nodes, updates the content directly. -/// For element nodes, removes all children and replaces with a text node. -/// -/// # Safety -/// -/// `doc` must be a valid mutable document pointer. `content` must be a valid -/// null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_set_text_content( - doc: *mut Document, - node: u32, - content: *const c_char, -) -> i32 { - if doc.is_null() || content.is_null() { - return 0; - } - let Some(node_id) = NodeId::from_raw(node) else { - return 0; - }; - // SAFETY: Null checks above. - let doc = unsafe { &mut *doc }; - let c_content = unsafe { std::ffi::CStr::from_ptr(content) }; - let Ok(text) = c_content.to_str() else { - return 0; - }; - i32::from(doc.set_text_content(node_id, text)) -} - -/// Sets an attribute on an element node. Returns 1 on success, 0 on failure. -/// -/// If the attribute already exists, its value is updated. -/// -/// # Safety -/// -/// `doc` must be a valid mutable document pointer. `name` and `value` must -/// be valid null-terminated UTF-8 strings. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_set_attribute( - doc: *mut Document, - node: u32, - name: *const c_char, - value: *const c_char, -) -> i32 { - if doc.is_null() || name.is_null() || value.is_null() { - return 0; - } - let Some(node_id) = NodeId::from_raw(node) else { - return 0; - }; - // SAFETY: Null checks above. - let doc = unsafe { &mut *doc }; - let c_name = unsafe { std::ffi::CStr::from_ptr(name) }; - let c_value = unsafe { std::ffi::CStr::from_ptr(value) }; - let Ok(name_str) = c_name.to_str() else { - return 0; - }; - let Ok(value_str) = c_value.to_str() else { - return 0; - }; - i32::from(doc.set_attribute(node_id, name_str, value_str)) -} - -/// Removes an attribute by name from an element node. -/// -/// Returns 1 if the attribute was removed, 0 if not found or not an element. -/// -/// # Safety -/// -/// `doc` must be a valid mutable document pointer. `name` must be a valid -/// null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_remove_attribute( - doc: *mut Document, - node: u32, - name: *const c_char, -) -> i32 { - if doc.is_null() || name.is_null() { - return 0; - } - let Some(node_id) = NodeId::from_raw(node) else { - return 0; - }; - // SAFETY: Null checks above. - let doc = unsafe { &mut *doc }; - let c_name = unsafe { std::ffi::CStr::from_ptr(name) }; - let Ok(name_str) = c_name.to_str() else { - return 0; - }; - i32::from(doc.remove_attribute(node_id, name_str)) -} - -/// Inserts a node before a reference sibling. Returns 1 on success, 0 on failure. -/// -/// # Safety -/// -/// `doc` must be a valid mutable document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_insert_before( - doc: *mut Document, - reference: u32, - new_child: u32, -) -> i32 { - let Some((doc, ref_id)) = (unsafe { doc_and_node_mut(doc, reference) }) else { - return 0; - }; - let Some(child_id) = NodeId::from_raw(new_child) else { - return 0; - }; - doc.insert_before(ref_id, child_id); - 1 -} - -/// Returns the element with the given ID attribute, or 0 if not found. -/// -/// Note: the document's `id_map` must be populated first, typically by -/// running DTD validation with `xmloxide_validate_dtd`. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. `id` must be a valid -/// null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_element_by_id(doc: *const Document, id: *const c_char) -> u32 { - if doc.is_null() || id.is_null() { - return 0; - } - // SAFETY: Null checks above. - let doc = unsafe { &*doc }; - let c_id = unsafe { std::ffi::CStr::from_ptr(id) }; - let Ok(id_str) = c_id.to_str() else { - return 0; - }; - node_id_to_raw(doc.element_by_id(id_str)) -} - -/// Inserts a node after a reference sibling. Returns 1 on success, 0 on failure. -/// -/// # Safety -/// -/// `doc` must be a valid mutable document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_insert_after( - doc: *mut Document, - reference: u32, - new_child: u32, -) -> i32 { - let Some((doc, ref_id)) = (unsafe { doc_and_node_mut(doc, reference) }) else { - return 0; - }; - let Some(child_id) = NodeId::from_raw(new_child) else { - return 0; - }; - doc.insert_after(ref_id, child_id); - 1 -} - -/// Replaces a node in the tree with another. Returns 1 on success, 0 on failure. -/// -/// The old node is detached and the new node takes its position. -/// -/// # Safety -/// -/// `doc` must be a valid mutable document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_replace_node( - doc: *mut Document, - old_node: u32, - new_node: u32, -) -> i32 { - let Some((doc, old_id)) = (unsafe { doc_and_node_mut(doc, old_node) }) else { - return 0; - }; - let Some(new_id) = NodeId::from_raw(new_node) else { - return 0; - }; - doc.replace_node(old_id, new_id); - 1 -} - -/// Creates a new processing instruction node and returns its id (0 on failure). -/// -/// # Safety -/// -/// `doc` must be a valid mutable document pointer. `target` must be a valid -/// null-terminated UTF-8 string. `data` may be null. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_create_pi( - doc: *mut Document, - target: *const c_char, - data: *const c_char, -) -> u32 { - if doc.is_null() || target.is_null() { - return 0; - } - // SAFETY: Null checks above. - let doc = unsafe { &mut *doc }; - let c_target = unsafe { std::ffi::CStr::from_ptr(target) }; - let Ok(target_str) = c_target.to_str() else { - return 0; - }; - let data_str = if data.is_null() { - None - } else { - let c_data = unsafe { std::ffi::CStr::from_ptr(data) }; - match c_data.to_str() { - Ok(s) => Some(s), - Err(_) => return 0, - } - }; - let node = doc.create_processing_instruction(target_str, data_str); - node.into_raw() -} - -/// Renames an element node. Returns 1 on success, 0 on failure. -/// -/// # Safety -/// -/// `doc` must be a valid mutable document pointer. `new_name` must be a valid -/// null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_rename_element( - doc: *mut Document, - node: u32, - new_name: *const c_char, -) -> i32 { - if doc.is_null() || new_name.is_null() { - return 0; - } - let Some(node_id) = NodeId::from_raw(node) else { - return 0; - }; - // SAFETY: Null checks above. - let doc = unsafe { &mut *doc }; - let c_name = unsafe { std::ffi::CStr::from_ptr(new_name) }; - let Ok(name_str) = c_name.to_str() else { - return 0; - }; - i32::from(doc.rename_element(node_id, name_str)) -} - -/// Returns the namespace prefix of an element node, or null if none. -/// -/// For example, returns `"svg"` for `<svg:rect>`. -/// The returned string must be freed with `xmloxide_free_string`. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_node_prefix(doc: *const Document, node: u32) -> *mut c_char { - let Some((doc, node_id)) = (unsafe { doc_and_node(doc, node) }) else { - return std::ptr::null_mut(); - }; - match doc.node_prefix(node_id) { - Some(prefix) => to_c_string(prefix), - None => std::ptr::null_mut(), - } -} diff --git a/browser/vendor/xmloxide/src/ffi/validation.rs b/browser/vendor/xmloxide/src/ffi/validation.rs deleted file mode 100644 index a41db9529..000000000 --- a/browser/vendor/xmloxide/src/ffi/validation.rs +++ /dev/null @@ -1,467 +0,0 @@ -//! Validation FFI functions (DTD, `RelaxNG`, XSD, Schematron). -#![allow(unsafe_code, clippy::missing_safety_doc)] - -use std::ffi::CStr; -use std::os::raw::c_char; - -use crate::tree::Document; -use crate::validation::dtd::{self, Dtd}; -use crate::validation::relaxng::{self, RelaxNgSchema}; -use crate::validation::schematron::{self, SchematronSchema}; -use crate::validation::xsd::{self, XsdSchema}; -use crate::validation::ValidationResult; - -use super::strings::to_c_string; -use super::{clear_last_error, set_last_error}; - -/// Parses a DTD from a null-terminated UTF-8 string. -/// -/// Returns a pointer to the DTD on success, or null on failure. -/// The returned DTD must be freed with [`xmloxide_free_dtd`]. -/// -/// # Safety -/// -/// `input` must be a valid null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_parse_dtd(input: *const c_char) -> *mut Dtd { - clear_last_error(); - if input.is_null() { - set_last_error("null input pointer"); - return std::ptr::null_mut(); - } - // SAFETY: Null check above. Caller guarantees valid null-terminated string. - let c_str = unsafe { CStr::from_ptr(input) }; - let Ok(s) = c_str.to_str() else { - set_last_error("invalid UTF-8"); - return std::ptr::null_mut(); - }; - match dtd::parse_dtd(s) { - Ok(dtd) => Box::into_raw(Box::new(dtd)), - Err(e) => { - set_last_error(&e.message); - std::ptr::null_mut() - } - } -} - -/// Frees a DTD previously returned by `xmloxide_parse_dtd`. -/// -/// Passing null is safe and does nothing. -/// -/// # Safety -/// -/// `dtd` must have been returned by `xmloxide_parse_dtd`, or be null. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_free_dtd(dtd: *mut Dtd) { - if !dtd.is_null() { - // SAFETY: `dtd` was created by `Box::into_raw`, and is non-null. - unsafe { - drop(Box::from_raw(dtd)); - } - } -} - -/// Validates a document against a DTD. -/// -/// Returns a pointer to the validation result on success, or null on failure. -/// The returned result must be freed with [`xmloxide_free_validation_result`]. -/// -/// Note: DTD validation may populate the document's `id_map`, so the -/// document pointer must be mutable. -/// -/// # Safety -/// -/// `doc` must be a valid mutable document pointer. `dtd` must be a valid DTD pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_validate_dtd( - doc: *mut Document, - dtd: *const Dtd, -) -> *mut ValidationResult { - clear_last_error(); - if doc.is_null() || dtd.is_null() { - set_last_error("null pointer argument"); - return std::ptr::null_mut(); - } - // SAFETY: Null checks above. Caller guarantees valid pointers. - let doc = unsafe { &mut *doc }; - let dtd = unsafe { &*dtd }; - let result = dtd::validate(doc, dtd); - Box::into_raw(Box::new(result)) -} - -/// Parses a `RelaxNG` schema from a null-terminated UTF-8 XML string. -/// -/// Returns a pointer to the schema on success, or null on failure. -/// The returned schema must be freed with [`xmloxide_free_relaxng`]. -/// -/// # Safety -/// -/// `input` must be a valid null-terminated UTF-8 string containing a `RelaxNG` schema. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_parse_relaxng(input: *const c_char) -> *mut RelaxNgSchema { - clear_last_error(); - if input.is_null() { - set_last_error("null input pointer"); - return std::ptr::null_mut(); - } - // SAFETY: Null check above. Caller guarantees valid null-terminated string. - let c_str = unsafe { CStr::from_ptr(input) }; - let Ok(s) = c_str.to_str() else { - set_last_error("invalid UTF-8"); - return std::ptr::null_mut(); - }; - match relaxng::parse_relaxng(s) { - Ok(schema) => Box::into_raw(Box::new(schema)), - Err(e) => { - set_last_error(&e.message); - std::ptr::null_mut() - } - } -} - -/// Frees a `RelaxNG` schema previously returned by `xmloxide_parse_relaxng`. -/// -/// Passing null is safe and does nothing. -/// -/// # Safety -/// -/// `schema` must have been returned by `xmloxide_parse_relaxng`, or be null. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_free_relaxng(schema: *mut RelaxNgSchema) { - if !schema.is_null() { - // SAFETY: `schema` was created by `Box::into_raw`, and is non-null. - unsafe { - drop(Box::from_raw(schema)); - } - } -} - -/// Validates a document against a `RelaxNG` schema. -/// -/// Returns a pointer to the validation result on success, or null on failure. -/// The returned result must be freed with [`xmloxide_free_validation_result`]. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. `schema` must be a valid `RelaxNG` schema pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_validate_relaxng( - doc: *const Document, - schema: *const RelaxNgSchema, -) -> *mut ValidationResult { - clear_last_error(); - if doc.is_null() || schema.is_null() { - set_last_error("null pointer argument"); - return std::ptr::null_mut(); - } - // SAFETY: Null checks above. Caller guarantees valid pointers. - let doc = unsafe { &*doc }; - let schema = unsafe { &*schema }; - let result = relaxng::validate(doc, schema); - Box::into_raw(Box::new(result)) -} - -/// Parses an XSD schema from a null-terminated UTF-8 XML string. -/// -/// Returns a pointer to the schema on success, or null on failure. -/// The returned schema must be freed with [`xmloxide_free_xsd`]. -/// -/// # Safety -/// -/// `input` must be a valid null-terminated UTF-8 string containing an XSD schema. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_parse_xsd(input: *const c_char) -> *mut XsdSchema { - clear_last_error(); - if input.is_null() { - set_last_error("null input pointer"); - return std::ptr::null_mut(); - } - // SAFETY: Null check above. Caller guarantees valid null-terminated string. - let c_str = unsafe { CStr::from_ptr(input) }; - let Ok(s) = c_str.to_str() else { - set_last_error("invalid UTF-8"); - return std::ptr::null_mut(); - }; - match xsd::parse_xsd(s) { - Ok(schema) => Box::into_raw(Box::new(schema)), - Err(e) => { - set_last_error(&e.message); - std::ptr::null_mut() - } - } -} - -/// Frees an XSD schema previously returned by `xmloxide_parse_xsd`. -/// -/// Passing null is safe and does nothing. -/// -/// # Safety -/// -/// `schema` must have been returned by `xmloxide_parse_xsd`, or be null. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_free_xsd(schema: *mut XsdSchema) { - if !schema.is_null() { - // SAFETY: `schema` was created by `Box::into_raw`, and is non-null. - unsafe { - drop(Box::from_raw(schema)); - } - } -} - -/// Validates a document against an XSD schema. -/// -/// Returns a pointer to the validation result on success, or null on failure. -/// The returned result must be freed with [`xmloxide_free_validation_result`]. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. `schema` must be a valid XSD schema pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_validate_xsd( - doc: *const Document, - schema: *const XsdSchema, -) -> *mut ValidationResult { - clear_last_error(); - if doc.is_null() || schema.is_null() { - set_last_error("null pointer argument"); - return std::ptr::null_mut(); - } - // SAFETY: Null checks above. Caller guarantees valid pointers. - let doc = unsafe { &*doc }; - let schema = unsafe { &*schema }; - let result = xsd::validate_xsd(doc, schema); - Box::into_raw(Box::new(result)) -} - -/// Returns whether the validation result indicates a valid document. -/// -/// Returns 1 for valid, 0 for invalid or null. -/// -/// # Safety -/// -/// `result` must be a valid validation result pointer, or null. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_validation_is_valid(result: *const ValidationResult) -> i32 { - if result.is_null() { - return 0; - } - // SAFETY: Null check above. Caller guarantees `result` is a valid pointer. - let result = unsafe { &*result }; - i32::from(result.is_valid) -} - -/// Returns the number of validation errors. -/// -/// Returns 0 if the result is null. -/// -/// # Safety -/// -/// `result` must be a valid validation result pointer, or null. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_validation_error_count(result: *const ValidationResult) -> usize { - if result.is_null() { - return 0; - } - // SAFETY: Null check above. - let result = unsafe { &*result }; - result.errors.len() -} - -/// Returns the error message at the given index. -/// -/// Returns null if the index is out of range. The returned string must be -/// freed with `xmloxide_free_string`. -/// -/// # Safety -/// -/// `result` must be a valid validation result pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_validation_error_message( - result: *const ValidationResult, - index: usize, -) -> *mut c_char { - if result.is_null() { - return std::ptr::null_mut(); - } - // SAFETY: Null check above. - let result = unsafe { &*result }; - match result.errors.get(index) { - Some(err) => to_c_string(&err.to_string()), - None => std::ptr::null_mut(), - } -} - -/// Returns the number of validation warnings. -/// -/// Returns 0 if the result is null. -/// -/// # Safety -/// -/// `result` must be a valid validation result pointer, or null. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_validation_warning_count( - result: *const ValidationResult, -) -> usize { - if result.is_null() { - return 0; - } - // SAFETY: Null check above. - let result = unsafe { &*result }; - result.warnings.len() -} - -/// Returns the warning message at the given index. -/// -/// Returns null if the index is out of range. The returned string must be -/// freed with `xmloxide_free_string`. -/// -/// # Safety -/// -/// `result` must be a valid validation result pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_validation_warning_message( - result: *const ValidationResult, - index: usize, -) -> *mut c_char { - if result.is_null() { - return std::ptr::null_mut(); - } - // SAFETY: Null check above. - let result = unsafe { &*result }; - match result.warnings.get(index) { - Some(warn) => to_c_string(&warn.to_string()), - None => std::ptr::null_mut(), - } -} - -/// Parses an ISO Schematron schema from a null-terminated UTF-8 XML string. -/// -/// Returns a pointer to the schema on success, or null on failure. -/// The returned schema must be freed with [`xmloxide_free_schematron`]. -/// -/// # Safety -/// -/// `input` must be a valid null-terminated UTF-8 string containing a Schematron schema. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_parse_schematron(input: *const c_char) -> *mut SchematronSchema { - clear_last_error(); - if input.is_null() { - set_last_error("null input pointer"); - return std::ptr::null_mut(); - } - // SAFETY: Null check above. Caller guarantees valid null-terminated string. - let c_str = unsafe { CStr::from_ptr(input) }; - let Ok(s) = c_str.to_str() else { - set_last_error("invalid UTF-8"); - return std::ptr::null_mut(); - }; - match schematron::parse_schematron(s) { - Ok(schema) => Box::into_raw(Box::new(schema)), - Err(e) => { - set_last_error(&e.message); - std::ptr::null_mut() - } - } -} - -/// Frees a Schematron schema previously returned by `xmloxide_parse_schematron`. -/// -/// Passing null is safe and does nothing. -/// -/// # Safety -/// -/// `schema` must have been returned by `xmloxide_parse_schematron`, or be null. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_free_schematron(schema: *mut SchematronSchema) { - if !schema.is_null() { - // SAFETY: `schema` was created by `Box::into_raw`, and is non-null. - unsafe { - drop(Box::from_raw(schema)); - } - } -} - -/// Validates a document against an ISO Schematron schema. -/// -/// Returns a pointer to the validation result on success, or null on failure. -/// The returned result must be freed with [`xmloxide_free_validation_result`]. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. `schema` must be a valid Schematron schema pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_validate_schematron( - doc: *const Document, - schema: *const SchematronSchema, -) -> *mut ValidationResult { - clear_last_error(); - if doc.is_null() || schema.is_null() { - set_last_error("null pointer argument"); - return std::ptr::null_mut(); - } - // SAFETY: Null checks above. Caller guarantees valid pointers. - let doc = unsafe { &*doc }; - let schema = unsafe { &*schema }; - let result = schematron::validate_schematron(doc, schema); - Box::into_raw(Box::new(result)) -} - -/// Validates a document against a Schematron schema using a specific phase. -/// -/// `phase` is the name of the phase to activate (null-terminated UTF-8). -/// If `phase` is null, all patterns are active (equivalent to -/// [`xmloxide_validate_schematron`]). -/// -/// Returns a pointer to the validation result on success, or null on failure. -/// The returned result must be freed with [`xmloxide_free_validation_result`]. -/// -/// # Safety -/// -/// `doc` and `schema` must be valid pointers. `phase` must be a valid -/// null-terminated UTF-8 string, or null. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_validate_schematron_with_phase( - doc: *const Document, - schema: *const SchematronSchema, - phase: *const c_char, -) -> *mut ValidationResult { - clear_last_error(); - if doc.is_null() || schema.is_null() { - set_last_error("null pointer argument"); - return std::ptr::null_mut(); - } - // SAFETY: Null checks above. Caller guarantees valid pointers. - let doc = unsafe { &*doc }; - let schema = unsafe { &*schema }; - - if phase.is_null() { - let result = schematron::validate_schematron(doc, schema); - return Box::into_raw(Box::new(result)); - } - - // SAFETY: Null check above. - let phase_raw = unsafe { CStr::from_ptr(phase) }; - let Ok(phase_name) = phase_raw.to_str() else { - set_last_error("invalid UTF-8 in phase name"); - return std::ptr::null_mut(); - }; - let result = schematron::validate_schematron_with_phase(doc, schema, phase_name); - Box::into_raw(Box::new(result)) -} - -/// Frees a validation result previously returned by a validate function. -/// -/// Passing null is safe and does nothing. -/// -/// # Safety -/// -/// `result` must have been returned by a validate function, or be null. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_free_validation_result(result: *mut ValidationResult) { - if !result.is_null() { - // SAFETY: `result` was created by `Box::into_raw`, and is non-null. - unsafe { - drop(Box::from_raw(result)); - } - } -} diff --git a/browser/vendor/xmloxide/src/ffi/xinclude.rs b/browser/vendor/xmloxide/src/ffi/xinclude.rs deleted file mode 100644 index 74bfedf83..000000000 --- a/browser/vendor/xmloxide/src/ffi/xinclude.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! `XInclude` processing FFI functions. -#![allow(unsafe_code, clippy::missing_safety_doc)] - -use crate::tree::Document; -use crate::xinclude::{self, XIncludeOptions}; - -use super::{clear_last_error, set_last_error}; - -/// Processes `XInclude` elements in a document using file-based resolution. -/// -/// Returns the number of successful inclusions, or -1 on failure. -/// On failure, call [`xmloxide_last_error`](super::xmloxide_last_error) for details. -/// -/// The resolver reads included files from the filesystem relative to the -/// working directory. -/// -/// # Safety -/// -/// `doc` must be a valid mutable document pointer. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_process_xincludes(doc: *mut Document) -> i32 { - clear_last_error(); - if doc.is_null() { - set_last_error("null document pointer"); - return -1; - } - // SAFETY: Null check above. - let doc = unsafe { &mut *doc }; - let resolver = |href: &str| std::fs::read_to_string(href).ok(); - let result = xinclude::process_xincludes(doc, resolver, &XIncludeOptions::default()); - let count = i32::try_from(result.inclusions).unwrap_or(i32::MAX); - if !result.errors.is_empty() { - let msgs: Vec<String> = result.errors.iter().map(|e| e.message.clone()).collect(); - set_last_error(&msgs.join("; ")); - } - count -} diff --git a/browser/vendor/xmloxide/src/ffi/xpath.rs b/browser/vendor/xmloxide/src/ffi/xpath.rs deleted file mode 100644 index 85bc628de..000000000 --- a/browser/vendor/xmloxide/src/ffi/xpath.rs +++ /dev/null @@ -1,305 +0,0 @@ -//! `XPath` evaluation FFI functions. -#![allow(unsafe_code, clippy::missing_safety_doc)] - -use std::ffi::CStr; -use std::os::raw::{c_char, c_int}; - -use crate::tree::{Document, NodeId}; -use crate::xpath::{XPathNode, XPathValue}; - -use super::strings::to_c_string; -use super::{clear_last_error, set_last_error}; - -/// `XPath` result type constants. -pub const XMLOXIDE_XPATH_NODESET: i32 = 1; -/// `XPath` boolean result type. -pub const XMLOXIDE_XPATH_BOOLEAN: i32 = 2; -/// `XPath` number result type. -pub const XMLOXIDE_XPATH_NUMBER: i32 = 3; -/// `XPath` string result type. -pub const XMLOXIDE_XPATH_STRING: i32 = 4; - -/// Evaluates an `XPath` expression against a context node. -/// -/// Returns a pointer to the result on success, or null on failure. -/// On failure, call [`xmloxide_last_error`](super::xmloxide_last_error) for details. -/// -/// The returned result must be freed with [`xmloxide_xpath_free_result`]. -/// -/// # Safety -/// -/// `doc` must be a valid document pointer. `expr` must be a valid -/// null-terminated UTF-8 string. `context_node` must be a valid node -/// id within the document (use 0 to use the document root). -#[no_mangle] -pub unsafe extern "C" fn xmloxide_xpath_eval( - doc: *const Document, - context_node: u32, - expr: *const c_char, -) -> *mut XPathValue { - clear_last_error(); - if doc.is_null() || expr.is_null() { - set_last_error("null pointer argument"); - return std::ptr::null_mut(); - } - // SAFETY: Null checks above. Caller guarantees both pointers are valid. - let doc = unsafe { &*doc }; - // SAFETY: Null check above. Caller guarantees `expr` is a valid null-terminated string. - let c_expr = unsafe { CStr::from_ptr(expr) }; - let Ok(expr_str) = c_expr.to_str() else { - set_last_error("invalid UTF-8 in XPath expression"); - return std::ptr::null_mut(); - }; - - let ctx_node = if context_node == 0 { - doc.root() - } else if let Some(id) = NodeId::from_raw(context_node) { - id - } else { - set_last_error("invalid node id"); - return std::ptr::null_mut(); - }; - - match crate::xpath::evaluate(doc, ctx_node, expr_str) { - Ok(val) => Box::into_raw(Box::new(val)), - Err(e) => { - set_last_error(&format!("{e}")); - std::ptr::null_mut() - } - } -} - -/// Returns the type of an `XPath` result. -/// -/// Returns one of the `XMLOXIDE_XPATH_*` constants, or -1 on error. -/// -/// # Safety -/// -/// `result` must be a valid pointer returned by `xmloxide_xpath_eval`. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_xpath_result_type(result: *const XPathValue) -> i32 { - if result.is_null() { - return -1; - } - // SAFETY: Null check above. Caller guarantees `result` is a valid pointer from `xmloxide_xpath_eval`. - let val = unsafe { &*result }; - match val { - XPathValue::NodeSet(_) => XMLOXIDE_XPATH_NODESET, - XPathValue::Boolean(_) => XMLOXIDE_XPATH_BOOLEAN, - XPathValue::Number(_) => XMLOXIDE_XPATH_NUMBER, - XPathValue::String(_) => XMLOXIDE_XPATH_STRING, - } -} - -/// Returns the boolean value of an `XPath` result. -/// -/// Converts non-boolean results using `XPath` type coercion rules. -/// -/// # Safety -/// -/// `result` must be a valid pointer returned by `xmloxide_xpath_eval`. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_xpath_result_boolean(result: *const XPathValue) -> i32 { - if result.is_null() { - return 0; - } - // SAFETY: Null check above. Caller guarantees `result` is a valid pointer from `xmloxide_xpath_eval`. - let val = unsafe { &*result }; - i32::from(val.to_boolean()) -} - -/// Returns the numeric value of an `XPath` result. -/// -/// Converts non-number results using `XPath` type coercion rules. -/// -/// # Safety -/// -/// `result` must be a valid pointer returned by `xmloxide_xpath_eval`. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_xpath_result_number(result: *const XPathValue) -> f64 { - if result.is_null() { - return f64::NAN; - } - // SAFETY: Null check above. Caller guarantees `result` is a valid pointer from `xmloxide_xpath_eval`. - let val = unsafe { &*result }; - val.to_number() -} - -/// Returns the string value of an `XPath` result. -/// -/// Converts non-string results using `XPath` type coercion rules. -/// The returned string must be freed with `xmloxide_free_string`. -/// -/// # Safety -/// -/// `result` must be a valid pointer returned by `xmloxide_xpath_eval`. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_xpath_result_string(result: *const XPathValue) -> *mut c_char { - if result.is_null() { - return std::ptr::null_mut(); - } - // SAFETY: Null check above. Caller guarantees `result` is a valid pointer from `xmloxide_xpath_eval`. - let val = unsafe { &*result }; - to_c_string(&val.to_xpath_string()) -} - -/// Returns the number of nodes in an `XPath` nodeset result. -/// -/// Returns 0 if the result is not a nodeset. -/// -/// # Safety -/// -/// `result` must be a valid pointer returned by `xmloxide_xpath_eval`. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_xpath_nodeset_count(result: *const XPathValue) -> usize { - if result.is_null() { - return 0; - } - // SAFETY: Null check above. Caller guarantees `result` is a valid pointer from `xmloxide_xpath_eval`. - let val = unsafe { &*result }; - match val { - XPathValue::NodeSet(nodes) => nodes.len(), - _ => 0, - } -} - -/// Returns the node id at the given index in an `XPath` nodeset result. -/// -/// For attribute nodes, returns the id of the owner element (use -/// `xmloxide_xpath_nodeset_item_is_attribute`, -/// `xmloxide_xpath_nodeset_item_attr_name`, and -/// `xmloxide_xpath_nodeset_item_attr_value` to inspect the attribute). -/// -/// Returns 0 if the result is not a nodeset or the index is out of bounds. -/// -/// # Safety -/// -/// `result` must be a valid pointer returned by `xmloxide_xpath_eval`. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_xpath_nodeset_item( - result: *const XPathValue, - index: usize, -) -> u32 { - if result.is_null() { - return 0; - } - // SAFETY: Null check above. Caller guarantees `result` is a valid pointer from `xmloxide_xpath_eval`. - let val = unsafe { &*result }; - match val { - XPathValue::NodeSet(nodes) => nodes.get(index).map_or(0, |n| n.anchor().into_raw()), - _ => 0, - } -} - -/// Returns 1 if the nodeset entry at `index` is an attribute node, 0 -/// otherwise (including out-of-bounds and non-nodeset results). -/// -/// # Safety -/// -/// `result` must be a valid pointer returned by `xmloxide_xpath_eval`. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_xpath_nodeset_item_is_attribute( - result: *const XPathValue, - index: usize, -) -> c_int { - if result.is_null() { - return 0; - } - // SAFETY: Null check above. Caller guarantees `result` is a valid pointer from `xmloxide_xpath_eval`. - let val = unsafe { &*result }; - match val { - XPathValue::NodeSet(nodes) => { - c_int::from(nodes.get(index).is_some_and(|n| n.is_attribute())) - } - _ => 0, - } -} - -/// Returns the qualified name of the attribute at `index` in a nodeset -/// result, or null if the entry is not an attribute. -/// -/// The returned string must be freed with `xmloxide_free_string`. -/// -/// # Safety -/// -/// `result` must be a valid pointer returned by `xmloxide_xpath_eval`, and -/// `doc` must be the document the result was evaluated against. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_xpath_nodeset_item_attr_name( - doc: *const Document, - result: *const XPathValue, - index: usize, -) -> *mut c_char { - // SAFETY: Null checks below; caller guarantees validity per the contract. - let (doc, val) = unsafe { - match (doc.as_ref(), result.as_ref()) { - (Some(d), Some(v)) => (d, v), - _ => return std::ptr::null_mut(), - } - }; - let XPathValue::NodeSet(nodes) = val else { - return std::ptr::null_mut(); - }; - let Some(&XPathNode::Attribute { owner, index }) = nodes.get(index) else { - return std::ptr::null_mut(); - }; - let Some(attr) = doc.attributes(owner).get(index as usize) else { - return std::ptr::null_mut(); - }; - let qname = match &attr.prefix { - Some(prefix) => format!("{prefix}:{}", attr.name), - None => attr.name.clone(), - }; - to_c_string(&qname) -} - -/// Returns the value of the attribute at `index` in a nodeset result, or -/// null if the entry is not an attribute. -/// -/// The returned string must be freed with `xmloxide_free_string`. -/// -/// # Safety -/// -/// `result` must be a valid pointer returned by `xmloxide_xpath_eval`, and -/// `doc` must be the document the result was evaluated against. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_xpath_nodeset_item_attr_value( - doc: *const Document, - result: *const XPathValue, - index: usize, -) -> *mut c_char { - // SAFETY: Null checks below; caller guarantees validity per the contract. - let (doc, val) = unsafe { - match (doc.as_ref(), result.as_ref()) { - (Some(d), Some(v)) => (d, v), - _ => return std::ptr::null_mut(), - } - }; - let XPathValue::NodeSet(nodes) = val else { - return std::ptr::null_mut(); - }; - let Some(&XPathNode::Attribute { owner, index }) = nodes.get(index) else { - return std::ptr::null_mut(); - }; - let Some(attr) = doc.attributes(owner).get(index as usize) else { - return std::ptr::null_mut(); - }; - to_c_string(&attr.value) -} - -/// Frees an `XPath` result previously returned by `xmloxide_xpath_eval`. -/// -/// Passing null is safe and does nothing. -/// -/// # Safety -/// -/// `result` must have been returned by `xmloxide_xpath_eval`, or be null. -#[no_mangle] -pub unsafe extern "C" fn xmloxide_xpath_free_result(result: *mut XPathValue) { - if !result.is_null() { - // SAFETY: `result` was created by `Box::into_raw` in `xmloxide_xpath_eval`, and is non-null. - unsafe { - drop(Box::from_raw(result)); - } - } -} diff --git a/browser/vendor/xmloxide/src/html5/entities.rs b/browser/vendor/xmloxide/src/html5/entities.rs deleted file mode 100644 index dca7ae738..000000000 --- a/browser/vendor/xmloxide/src/html5/entities.rs +++ /dev/null @@ -1,2318 +0,0 @@ -//! HTML5 named character references. -//! -//! This module provides a lookup table for the full set of HTML5 named -//! character references as defined by the WHATWG HTML Living Standard. -//! This extends far beyond the HTML 4.01 entity set (252 entities) to -//! cover 2,125 named references including mathematical operators, arrows, -//! letterlike symbols, and combining characters. -//! -//! Some entities expand to multiple Unicode code points (e.g., `NotEqualTilde` -//! maps to U+2242 U+0338). These are represented as multi-character `&str` values. -//! -//! See <https://html.spec.whatwg.org/multipage/named-characters.html> - -/// Looks up an HTML5 named character reference and returns the corresponding -/// Unicode character(s) as a string slice. -/// -/// Returns `None` if the name is not a recognized HTML5 entity. The entity name -/// should be provided without the leading `&` and trailing `;`. -/// -/// # Examples -/// -/// ``` -/// use xmloxide::html5::entities::lookup_entity; -/// -/// assert_eq!(lookup_entity("nbsp"), Some("\u{00A0}")); -/// assert_eq!(lookup_entity("copy"), Some("\u{00A9}")); -/// assert_eq!(lookup_entity("NotEqualTilde"), Some("\u{2242}\u{0338}")); -/// assert_eq!(lookup_entity("nonexistent"), None); -/// ``` -pub fn lookup_entity(name: &str) -> Option<&'static str> { - // Binary search on the sorted entity table. - ENTITIES - .binary_search_by_key(&name, |&(n, _)| n) - .ok() - .map(|i| ENTITIES[i].1) -} - -/// Looks up the HTML5 named entity for a given character (reverse lookup). -/// -/// Returns `None` if no named entity exists for the character, or if the -/// character is one of the XML builtins (`&`, `<`, `>`, `'`, `"`) which -/// are handled separately by the escaping logic. -/// -/// Note that this only matches single-character entities. Entities that expand -/// to multiple code points cannot be reverse-looked-up by a single `char`. -/// When multiple entity names map to the same character, the first name in -/// alphabetical order is returned. -/// -/// Used by the HTML serializer to re-encode non-ASCII characters as their -/// named entity form (e.g., \u{00A9} to `&copy;`, \u{00A0} to `&nbsp;`). -/// -/// # Examples -/// -/// ``` -/// use xmloxide::html5::entities::reverse_lookup_entity; -/// -/// assert_eq!(reverse_lookup_entity('\u{0161}'), Some("scaron")); -/// assert_eq!(reverse_lookup_entity('\u{20AC}'), Some("euro")); -/// assert_eq!(reverse_lookup_entity('A'), None); -/// ``` -pub fn reverse_lookup_entity(ch: char) -> Option<&'static str> { - let mut buf = [0u8; 4]; - let target = ch.encode_utf8(&mut buf); - for &(name, value) in ENTITIES { - if value == target { - return Some(name); - } - } - None -} - -/// Returns `true` if the given entity name is a legacy named character reference -/// that is valid without a trailing semicolon. -/// -/// Per the WHATWG spec, these are the 106 named character references from -/// HTML 4.01 that are recognized even without a terminating `;`. -pub fn is_legacy_named_entity(name: &str) -> bool { - LEGACY_ENTITIES.binary_search(&name).is_ok() -} - -/// The 106 legacy named character references that are valid without a -/// trailing semicolon, sorted for binary search. -static LEGACY_ENTITIES: &[&str] = &[ - "AElig", "AMP", "Aacute", "Acirc", "Agrave", "Aring", "Atilde", "Auml", "COPY", "Ccedil", - "ETH", "Eacute", "Ecirc", "Egrave", "Euml", "GT", "Iacute", "Icirc", "Igrave", "Iuml", "LT", - "Ntilde", "Oacute", "Ocirc", "Ograve", "Oslash", "Otilde", "Ouml", "QUOT", "REG", "THORN", - "Uacute", "Ucirc", "Ugrave", "Uuml", "Yacute", "aacute", "acirc", "acute", "aelig", "agrave", - "amp", "aring", "atilde", "auml", "brvbar", "ccedil", "cedil", "cent", "copy", "curren", "deg", - "divide", "eacute", "ecirc", "egrave", "eth", "euml", "frac12", "frac14", "frac34", "gt", - "iacute", "icirc", "iexcl", "igrave", "iquest", "iuml", "laquo", "lt", "macr", "micro", - "middot", "nbsp", "not", "ntilde", "oacute", "ocirc", "ograve", "ordf", "ordm", "oslash", - "otilde", "ouml", "para", "plusmn", "pound", "quot", "raquo", "reg", "sect", "shy", "sup1", - "sup2", "sup3", "szlig", "thorn", "times", "uacute", "ucirc", "ugrave", "uml", "uuml", - "yacute", "yen", "yuml", -]; - -/// The HTML5 named character reference table, sorted by name for binary -/// search. Each entry is `(entity_name, replacement_str)`. -/// -/// This covers all 2125 named character references defined in the -/// WHATWG HTML Living Standard, including single- and multi-codepoint entities. -static ENTITIES: &[(&str, &str)] = &[ - ("AElig", "\u{00C6}"), - ("AMP", "&"), - ("Aacute", "\u{00C1}"), - ("Abreve", "\u{0102}"), - ("Acirc", "\u{00C2}"), - ("Acy", "\u{0410}"), - ("Afr", "\u{1D504}"), - ("Agrave", "\u{00C0}"), - ("Alpha", "\u{0391}"), - ("Amacr", "\u{0100}"), - ("And", "\u{2A53}"), - ("Aogon", "\u{0104}"), - ("Aopf", "\u{1D538}"), - ("ApplyFunction", "\u{2061}"), - ("Aring", "\u{00C5}"), - ("Ascr", "\u{1D49C}"), - ("Assign", "\u{2254}"), - ("Atilde", "\u{00C3}"), - ("Auml", "\u{00C4}"), - ("Backslash", "\u{2216}"), - ("Barv", "\u{2AE7}"), - ("Barwed", "\u{2306}"), - ("Bcy", "\u{0411}"), - ("Because", "\u{2235}"), - ("Bernoullis", "\u{212C}"), - ("Beta", "\u{0392}"), - ("Bfr", "\u{1D505}"), - ("Bopf", "\u{1D539}"), - ("Breve", "\u{02D8}"), - ("Bscr", "\u{212C}"), - ("Bumpeq", "\u{224E}"), - ("CHcy", "\u{0427}"), - ("COPY", "\u{00A9}"), - ("Cacute", "\u{0106}"), - ("Cap", "\u{22D2}"), - ("CapitalDifferentialD", "\u{2145}"), - ("Cayleys", "\u{212D}"), - ("Ccaron", "\u{010C}"), - ("Ccedil", "\u{00C7}"), - ("Ccirc", "\u{0108}"), - ("Cconint", "\u{2230}"), - ("Cdot", "\u{010A}"), - ("Cedilla", "\u{00B8}"), - ("CenterDot", "\u{00B7}"), - ("Cfr", "\u{212D}"), - ("Chi", "\u{03A7}"), - ("CircleDot", "\u{2299}"), - ("CircleMinus", "\u{2296}"), - ("CirclePlus", "\u{2295}"), - ("CircleTimes", "\u{2297}"), - ("ClockwiseContourIntegral", "\u{2232}"), - ("CloseCurlyDoubleQuote", "\u{201D}"), - ("CloseCurlyQuote", "\u{2019}"), - ("Colon", "\u{2237}"), - ("Colone", "\u{2A74}"), - ("Congruent", "\u{2261}"), - ("Conint", "\u{222F}"), - ("ContourIntegral", "\u{222E}"), - ("Copf", "\u{2102}"), - ("Coproduct", "\u{2210}"), - ("CounterClockwiseContourIntegral", "\u{2233}"), - ("Cross", "\u{2A2F}"), - ("Cscr", "\u{1D49E}"), - ("Cup", "\u{22D3}"), - ("CupCap", "\u{224D}"), - ("DD", "\u{2145}"), - ("DDotrahd", "\u{2911}"), - ("DJcy", "\u{0402}"), - ("DScy", "\u{0405}"), - ("DZcy", "\u{040F}"), - ("Dagger", "\u{2021}"), - ("Darr", "\u{21A1}"), - ("Dashv", "\u{2AE4}"), - ("Dcaron", "\u{010E}"), - ("Dcy", "\u{0414}"), - ("Del", "\u{2207}"), - ("Delta", "\u{0394}"), - ("Dfr", "\u{1D507}"), - ("DiacriticalAcute", "\u{00B4}"), - ("DiacriticalDot", "\u{02D9}"), - ("DiacriticalDoubleAcute", "\u{02DD}"), - ("DiacriticalGrave", "\u{0060}"), - ("DiacriticalTilde", "\u{02DC}"), - ("Diamond", "\u{22C4}"), - ("DifferentialD", "\u{2146}"), - ("Dopf", "\u{1D53B}"), - ("Dot", "\u{00A8}"), - ("DotDot", "\u{20DC}"), - ("DotEqual", "\u{2250}"), - ("DoubleContourIntegral", "\u{222F}"), - ("DoubleDot", "\u{00A8}"), - ("DoubleDownArrow", "\u{21D3}"), - ("DoubleLeftArrow", "\u{21D0}"), - ("DoubleLeftRightArrow", "\u{21D4}"), - ("DoubleLeftTee", "\u{2AE4}"), - ("DoubleLongLeftArrow", "\u{27F8}"), - ("DoubleLongLeftRightArrow", "\u{27FA}"), - ("DoubleLongRightArrow", "\u{27F9}"), - ("DoubleRightArrow", "\u{21D2}"), - ("DoubleRightTee", "\u{22A8}"), - ("DoubleUpArrow", "\u{21D1}"), - ("DoubleUpDownArrow", "\u{21D5}"), - ("DoubleVerticalBar", "\u{2225}"), - ("DownArrow", "\u{2193}"), - ("DownArrowBar", "\u{2913}"), - ("DownArrowUpArrow", "\u{21F5}"), - ("DownBreve", "\u{0311}"), - ("DownLeftRightVector", "\u{2950}"), - ("DownLeftTeeVector", "\u{295E}"), - ("DownLeftVector", "\u{21BD}"), - ("DownLeftVectorBar", "\u{2956}"), - ("DownRightTeeVector", "\u{295F}"), - ("DownRightVector", "\u{21C1}"), - ("DownRightVectorBar", "\u{2957}"), - ("DownTee", "\u{22A4}"), - ("DownTeeArrow", "\u{21A7}"), - ("Downarrow", "\u{21D3}"), - ("Dscr", "\u{1D49F}"), - ("Dstrok", "\u{0110}"), - ("ENG", "\u{014A}"), - ("ETH", "\u{00D0}"), - ("Eacute", "\u{00C9}"), - ("Ecaron", "\u{011A}"), - ("Ecirc", "\u{00CA}"), - ("Ecy", "\u{042D}"), - ("Edot", "\u{0116}"), - ("Efr", "\u{1D508}"), - ("Egrave", "\u{00C8}"), - ("Element", "\u{2208}"), - ("Emacr", "\u{0112}"), - ("EmptySmallSquare", "\u{25FB}"), - ("EmptyVerySmallSquare", "\u{25AB}"), - ("Eogon", "\u{0118}"), - ("Eopf", "\u{1D53C}"), - ("Epsilon", "\u{0395}"), - ("Equal", "\u{2A75}"), - ("EqualTilde", "\u{2242}"), - ("Equilibrium", "\u{21CC}"), - ("Escr", "\u{2130}"), - ("Esim", "\u{2A73}"), - ("Eta", "\u{0397}"), - ("Euml", "\u{00CB}"), - ("Exists", "\u{2203}"), - ("ExponentialE", "\u{2147}"), - ("Fcy", "\u{0424}"), - ("Ffr", "\u{1D509}"), - ("FilledSmallSquare", "\u{25FC}"), - ("FilledVerySmallSquare", "\u{25AA}"), - ("Fopf", "\u{1D53D}"), - ("ForAll", "\u{2200}"), - ("Fouriertrf", "\u{2131}"), - ("Fscr", "\u{2131}"), - ("GJcy", "\u{0403}"), - ("GT", ">"), - ("Gamma", "\u{0393}"), - ("Gammad", "\u{03DC}"), - ("Gbreve", "\u{011E}"), - ("Gcedil", "\u{0122}"), - ("Gcirc", "\u{011C}"), - ("Gcy", "\u{0413}"), - ("Gdot", "\u{0120}"), - ("Gfr", "\u{1D50A}"), - ("Gg", "\u{22D9}"), - ("Gopf", "\u{1D53E}"), - ("GreaterEqual", "\u{2265}"), - ("GreaterEqualLess", "\u{22DB}"), - ("GreaterFullEqual", "\u{2267}"), - ("GreaterGreater", "\u{2AA2}"), - ("GreaterLess", "\u{2277}"), - ("GreaterSlantEqual", "\u{2A7E}"), - ("GreaterTilde", "\u{2273}"), - ("Gscr", "\u{1D4A2}"), - ("Gt", "\u{226B}"), - ("HARDcy", "\u{042A}"), - ("Hacek", "\u{02C7}"), - ("Hat", "\u{005E}"), - ("Hcirc", "\u{0124}"), - ("Hfr", "\u{210C}"), - ("HilbertSpace", "\u{210B}"), - ("Hopf", "\u{210D}"), - ("HorizontalLine", "\u{2500}"), - ("Hscr", "\u{210B}"), - ("Hstrok", "\u{0126}"), - ("HumpDownHump", "\u{224E}"), - ("HumpEqual", "\u{224F}"), - ("IEcy", "\u{0415}"), - ("IJlig", "\u{0132}"), - ("IOcy", "\u{0401}"), - ("Iacute", "\u{00CD}"), - ("Icirc", "\u{00CE}"), - ("Icy", "\u{0418}"), - ("Idot", "\u{0130}"), - ("Ifr", "\u{2111}"), - ("Igrave", "\u{00CC}"), - ("Im", "\u{2111}"), - ("Imacr", "\u{012A}"), - ("ImaginaryI", "\u{2148}"), - ("Implies", "\u{21D2}"), - ("Int", "\u{222C}"), - ("Integral", "\u{222B}"), - ("Intersection", "\u{22C2}"), - ("InvisibleComma", "\u{2063}"), - ("InvisibleTimes", "\u{2062}"), - ("Iogon", "\u{012E}"), - ("Iopf", "\u{1D540}"), - ("Iota", "\u{0399}"), - ("Iscr", "\u{2110}"), - ("Itilde", "\u{0128}"), - ("Iukcy", "\u{0406}"), - ("Iuml", "\u{00CF}"), - ("Jcirc", "\u{0134}"), - ("Jcy", "\u{0419}"), - ("Jfr", "\u{1D50D}"), - ("Jopf", "\u{1D541}"), - ("Jscr", "\u{1D4A5}"), - ("Jsercy", "\u{0408}"), - ("Jukcy", "\u{0404}"), - ("KHcy", "\u{0425}"), - ("KJcy", "\u{040C}"), - ("Kappa", "\u{039A}"), - ("Kcedil", "\u{0136}"), - ("Kcy", "\u{041A}"), - ("Kfr", "\u{1D50E}"), - ("Kopf", "\u{1D542}"), - ("Kscr", "\u{1D4A6}"), - ("LJcy", "\u{0409}"), - ("LT", "<"), - ("Lacute", "\u{0139}"), - ("Lambda", "\u{039B}"), - ("Lang", "\u{27EA}"), - ("Laplacetrf", "\u{2112}"), - ("Larr", "\u{219E}"), - ("Lcaron", "\u{013D}"), - ("Lcedil", "\u{013B}"), - ("Lcy", "\u{041B}"), - ("LeftAngleBracket", "\u{27E8}"), - ("LeftArrow", "\u{2190}"), - ("LeftArrowBar", "\u{21E4}"), - ("LeftArrowRightArrow", "\u{21C6}"), - ("LeftCeiling", "\u{2308}"), - ("LeftDoubleBracket", "\u{27E6}"), - ("LeftDownTeeVector", "\u{2961}"), - ("LeftDownVector", "\u{21C3}"), - ("LeftDownVectorBar", "\u{2959}"), - ("LeftFloor", "\u{230A}"), - ("LeftRightArrow", "\u{2194}"), - ("LeftRightVector", "\u{294E}"), - ("LeftTee", "\u{22A3}"), - ("LeftTeeArrow", "\u{21A4}"), - ("LeftTeeVector", "\u{295A}"), - ("LeftTriangle", "\u{22B2}"), - ("LeftTriangleBar", "\u{29CF}"), - ("LeftTriangleEqual", "\u{22B4}"), - ("LeftUpDownVector", "\u{2951}"), - ("LeftUpTeeVector", "\u{2960}"), - ("LeftUpVector", "\u{21BF}"), - ("LeftUpVectorBar", "\u{2958}"), - ("LeftVector", "\u{21BC}"), - ("LeftVectorBar", "\u{2952}"), - ("Leftarrow", "\u{21D0}"), - ("Leftrightarrow", "\u{21D4}"), - ("LessEqualGreater", "\u{22DA}"), - ("LessFullEqual", "\u{2266}"), - ("LessGreater", "\u{2276}"), - ("LessLess", "\u{2AA1}"), - ("LessSlantEqual", "\u{2A7D}"), - ("LessTilde", "\u{2272}"), - ("Lfr", "\u{1D50F}"), - ("Ll", "\u{22D8}"), - ("Lleftarrow", "\u{21DA}"), - ("Lmidot", "\u{013F}"), - ("LongLeftArrow", "\u{27F5}"), - ("LongLeftRightArrow", "\u{27F7}"), - ("LongRightArrow", "\u{27F6}"), - ("Longleftarrow", "\u{27F8}"), - ("Longleftrightarrow", "\u{27FA}"), - ("Longrightarrow", "\u{27F9}"), - ("Lopf", "\u{1D543}"), - ("LowerLeftArrow", "\u{2199}"), - ("LowerRightArrow", "\u{2198}"), - ("Lscr", "\u{2112}"), - ("Lsh", "\u{21B0}"), - ("Lstrok", "\u{0141}"), - ("Lt", "\u{226A}"), - ("Map", "\u{2905}"), - ("Mcy", "\u{041C}"), - ("MediumSpace", "\u{205F}"), - ("Mellintrf", "\u{2133}"), - ("Mfr", "\u{1D510}"), - ("MinusPlus", "\u{2213}"), - ("Mopf", "\u{1D544}"), - ("Mscr", "\u{2133}"), - ("Mu", "\u{039C}"), - ("NJcy", "\u{040A}"), - ("Nacute", "\u{0143}"), - ("Ncaron", "\u{0147}"), - ("Ncedil", "\u{0145}"), - ("Ncy", "\u{041D}"), - ("NegativeMediumSpace", "\u{200B}"), - ("NegativeThickSpace", "\u{200B}"), - ("NegativeThinSpace", "\u{200B}"), - ("NegativeVeryThinSpace", "\u{200B}"), - ("NestedGreaterGreater", "\u{226B}"), - ("NestedLessLess", "\u{226A}"), - ("NewLine", "\n"), - ("Nfr", "\u{1D511}"), - ("NoBreak", "\u{2060}"), - ("NonBreakingSpace", "\u{00A0}"), - ("Nopf", "\u{2115}"), - ("Not", "\u{2AEC}"), - ("NotCongruent", "\u{2262}"), - ("NotCupCap", "\u{226D}"), - ("NotDoubleVerticalBar", "\u{2226}"), - ("NotElement", "\u{2209}"), - ("NotEqual", "\u{2260}"), - ("NotEqualTilde", "\u{2242}\u{0338}"), - ("NotExists", "\u{2204}"), - ("NotGreater", "\u{226F}"), - ("NotGreaterEqual", "\u{2271}"), - ("NotGreaterFullEqual", "\u{2267}\u{0338}"), - ("NotGreaterGreater", "\u{226B}\u{0338}"), - ("NotGreaterLess", "\u{2279}"), - ("NotGreaterSlantEqual", "\u{2A7E}\u{0338}"), - ("NotGreaterTilde", "\u{2275}"), - ("NotHumpDownHump", "\u{224E}\u{0338}"), - ("NotHumpEqual", "\u{224F}\u{0338}"), - ("NotLeftTriangle", "\u{22EA}"), - ("NotLeftTriangleBar", "\u{29CF}\u{0338}"), - ("NotLeftTriangleEqual", "\u{22EC}"), - ("NotLess", "\u{226E}"), - ("NotLessEqual", "\u{2270}"), - ("NotLessGreater", "\u{2278}"), - ("NotLessLess", "\u{226A}\u{0338}"), - ("NotLessSlantEqual", "\u{2A7D}\u{0338}"), - ("NotLessTilde", "\u{2274}"), - ("NotNestedGreaterGreater", "\u{2AA2}\u{0338}"), - ("NotNestedLessLess", "\u{2AA1}\u{0338}"), - ("NotPrecedes", "\u{2280}"), - ("NotPrecedesEqual", "\u{2AAF}\u{0338}"), - ("NotPrecedesSlantEqual", "\u{22E0}"), - ("NotReverseElement", "\u{220C}"), - ("NotRightTriangle", "\u{22EB}"), - ("NotRightTriangleBar", "\u{29D0}\u{0338}"), - ("NotRightTriangleEqual", "\u{22ED}"), - ("NotSquareSubset", "\u{228F}\u{0338}"), - ("NotSquareSubsetEqual", "\u{22E2}"), - ("NotSquareSuperset", "\u{2290}\u{0338}"), - ("NotSquareSupersetEqual", "\u{22E3}"), - ("NotSubset", "\u{2282}\u{20D2}"), - ("NotSubsetEqual", "\u{2288}"), - ("NotSucceeds", "\u{2281}"), - ("NotSucceedsEqual", "\u{2AB0}\u{0338}"), - ("NotSucceedsSlantEqual", "\u{22E1}"), - ("NotSucceedsTilde", "\u{227F}\u{0338}"), - ("NotSuperset", "\u{2283}\u{20D2}"), - ("NotSupersetEqual", "\u{2289}"), - ("NotTilde", "\u{2241}"), - ("NotTildeEqual", "\u{2244}"), - ("NotTildeFullEqual", "\u{2247}"), - ("NotTildeTilde", "\u{2249}"), - ("NotVerticalBar", "\u{2224}"), - ("Nscr", "\u{1D4A9}"), - ("Ntilde", "\u{00D1}"), - ("Nu", "\u{039D}"), - ("OElig", "\u{0152}"), - ("Oacute", "\u{00D3}"), - ("Ocirc", "\u{00D4}"), - ("Ocy", "\u{041E}"), - ("Odblac", "\u{0150}"), - ("Ofr", "\u{1D512}"), - ("Ograve", "\u{00D2}"), - ("Omacr", "\u{014C}"), - ("Omega", "\u{03A9}"), - ("Omicron", "\u{039F}"), - ("Oopf", "\u{1D546}"), - ("OpenCurlyDoubleQuote", "\u{201C}"), - ("OpenCurlyQuote", "\u{2018}"), - ("Or", "\u{2A54}"), - ("Oscr", "\u{1D4AA}"), - ("Oslash", "\u{00D8}"), - ("Otilde", "\u{00D5}"), - ("Otimes", "\u{2A37}"), - ("Ouml", "\u{00D6}"), - ("OverBar", "\u{203E}"), - ("OverBrace", "\u{23DE}"), - ("OverBracket", "\u{23B4}"), - ("OverParenthesis", "\u{23DC}"), - ("PartialD", "\u{2202}"), - ("Pcy", "\u{041F}"), - ("Pfr", "\u{1D513}"), - ("Phi", "\u{03A6}"), - ("Pi", "\u{03A0}"), - ("PlusMinus", "\u{00B1}"), - ("Poincareplane", "\u{210C}"), - ("Popf", "\u{2119}"), - ("Pr", "\u{2ABB}"), - ("Precedes", "\u{227A}"), - ("PrecedesEqual", "\u{2AAF}"), - ("PrecedesSlantEqual", "\u{227C}"), - ("PrecedesTilde", "\u{227E}"), - ("Prime", "\u{2033}"), - ("Product", "\u{220F}"), - ("Proportion", "\u{2237}"), - ("Proportional", "\u{221D}"), - ("Pscr", "\u{1D4AB}"), - ("Psi", "\u{03A8}"), - ("QUOT", "\""), - ("Qfr", "\u{1D514}"), - ("Qopf", "\u{211A}"), - ("Qscr", "\u{1D4AC}"), - ("RBarr", "\u{2910}"), - ("REG", "\u{00AE}"), - ("Racute", "\u{0154}"), - ("Rang", "\u{27EB}"), - ("Rarr", "\u{21A0}"), - ("Rarrtl", "\u{2916}"), - ("Rcaron", "\u{0158}"), - ("Rcedil", "\u{0156}"), - ("Rcy", "\u{0420}"), - ("Re", "\u{211C}"), - ("ReverseElement", "\u{220B}"), - ("ReverseEquilibrium", "\u{21CB}"), - ("ReverseUpEquilibrium", "\u{296F}"), - ("Rfr", "\u{211C}"), - ("Rho", "\u{03A1}"), - ("RightAngleBracket", "\u{27E9}"), - ("RightArrow", "\u{2192}"), - ("RightArrowBar", "\u{21E5}"), - ("RightArrowLeftArrow", "\u{21C4}"), - ("RightCeiling", "\u{2309}"), - ("RightDoubleBracket", "\u{27E7}"), - ("RightDownTeeVector", "\u{295D}"), - ("RightDownVector", "\u{21C2}"), - ("RightDownVectorBar", "\u{2955}"), - ("RightFloor", "\u{230B}"), - ("RightTee", "\u{22A2}"), - ("RightTeeArrow", "\u{21A6}"), - ("RightTeeVector", "\u{295B}"), - ("RightTriangle", "\u{22B3}"), - ("RightTriangleBar", "\u{29D0}"), - ("RightTriangleEqual", "\u{22B5}"), - ("RightUpDownVector", "\u{294F}"), - ("RightUpTeeVector", "\u{295C}"), - ("RightUpVector", "\u{21BE}"), - ("RightUpVectorBar", "\u{2954}"), - ("RightVector", "\u{21C0}"), - ("RightVectorBar", "\u{2953}"), - ("Rightarrow", "\u{21D2}"), - ("Ropf", "\u{211D}"), - ("RoundImplies", "\u{2970}"), - ("Rrightarrow", "\u{21DB}"), - ("Rscr", "\u{211B}"), - ("Rsh", "\u{21B1}"), - ("RuleDelayed", "\u{29F4}"), - ("SHCHcy", "\u{0429}"), - ("SHcy", "\u{0428}"), - ("SOFTcy", "\u{042C}"), - ("Sacute", "\u{015A}"), - ("Sc", "\u{2ABC}"), - ("Scaron", "\u{0160}"), - ("Scedil", "\u{015E}"), - ("Scirc", "\u{015C}"), - ("Scy", "\u{0421}"), - ("Sfr", "\u{1D516}"), - ("ShortDownArrow", "\u{2193}"), - ("ShortLeftArrow", "\u{2190}"), - ("ShortRightArrow", "\u{2192}"), - ("ShortUpArrow", "\u{2191}"), - ("Sigma", "\u{03A3}"), - ("SmallCircle", "\u{2218}"), - ("Sopf", "\u{1D54A}"), - ("Sqrt", "\u{221A}"), - ("Square", "\u{25A1}"), - ("SquareIntersection", "\u{2293}"), - ("SquareSubset", "\u{228F}"), - ("SquareSubsetEqual", "\u{2291}"), - ("SquareSuperset", "\u{2290}"), - ("SquareSupersetEqual", "\u{2292}"), - ("SquareUnion", "\u{2294}"), - ("Sscr", "\u{1D4AE}"), - ("Star", "\u{22C6}"), - ("Sub", "\u{22D0}"), - ("Subset", "\u{22D0}"), - ("SubsetEqual", "\u{2286}"), - ("Succeeds", "\u{227B}"), - ("SucceedsEqual", "\u{2AB0}"), - ("SucceedsSlantEqual", "\u{227D}"), - ("SucceedsTilde", "\u{227F}"), - ("SuchThat", "\u{220B}"), - ("Sum", "\u{2211}"), - ("Sup", "\u{22D1}"), - ("Superset", "\u{2283}"), - ("SupersetEqual", "\u{2287}"), - ("Supset", "\u{22D1}"), - ("THORN", "\u{00DE}"), - ("TRADE", "\u{2122}"), - ("TSHcy", "\u{040B}"), - ("TScy", "\u{0426}"), - ("Tab", "\t"), - ("Tau", "\u{03A4}"), - ("Tcaron", "\u{0164}"), - ("Tcedil", "\u{0162}"), - ("Tcy", "\u{0422}"), - ("Tfr", "\u{1D517}"), - ("Therefore", "\u{2234}"), - ("Theta", "\u{0398}"), - ("ThickSpace", "\u{205F}\u{200A}"), - ("ThinSpace", "\u{2009}"), - ("Tilde", "\u{223C}"), - ("TildeEqual", "\u{2243}"), - ("TildeFullEqual", "\u{2245}"), - ("TildeTilde", "\u{2248}"), - ("Topf", "\u{1D54B}"), - ("TripleDot", "\u{20DB}"), - ("Tscr", "\u{1D4AF}"), - ("Tstrok", "\u{0166}"), - ("Uacute", "\u{00DA}"), - ("Uarr", "\u{219F}"), - ("Uarrocir", "\u{2949}"), - ("Ubrcy", "\u{040E}"), - ("Ubreve", "\u{016C}"), - ("Ucirc", "\u{00DB}"), - ("Ucy", "\u{0423}"), - ("Udblac", "\u{0170}"), - ("Ufr", "\u{1D518}"), - ("Ugrave", "\u{00D9}"), - ("Umacr", "\u{016A}"), - ("UnderBar", "\u{005F}"), - ("UnderBrace", "\u{23DF}"), - ("UnderBracket", "\u{23B5}"), - ("UnderParenthesis", "\u{23DD}"), - ("Union", "\u{22C3}"), - ("UnionPlus", "\u{228E}"), - ("Uogon", "\u{0172}"), - ("Uopf", "\u{1D54C}"), - ("UpArrow", "\u{2191}"), - ("UpArrowBar", "\u{2912}"), - ("UpArrowDownArrow", "\u{21C5}"), - ("UpDownArrow", "\u{2195}"), - ("UpEquilibrium", "\u{296E}"), - ("UpTee", "\u{22A5}"), - ("UpTeeArrow", "\u{21A5}"), - ("Uparrow", "\u{21D1}"), - ("Updownarrow", "\u{21D5}"), - ("UpperLeftArrow", "\u{2196}"), - ("UpperRightArrow", "\u{2197}"), - ("Upsi", "\u{03D2}"), - ("Upsilon", "\u{03A5}"), - ("Uring", "\u{016E}"), - ("Uscr", "\u{1D4B0}"), - ("Utilde", "\u{0168}"), - ("Uuml", "\u{00DC}"), - ("VDash", "\u{22AB}"), - ("Vbar", "\u{2AEB}"), - ("Vcy", "\u{0412}"), - ("Vdash", "\u{22A9}"), - ("Vdashl", "\u{2AE6}"), - ("Vee", "\u{22C1}"), - ("Verbar", "\u{2016}"), - ("Vert", "\u{2016}"), - ("VerticalBar", "\u{2223}"), - ("VerticalLine", "\u{007C}"), - ("VerticalSeparator", "\u{2758}"), - ("VerticalTilde", "\u{2240}"), - ("VeryThinSpace", "\u{200A}"), - ("Vfr", "\u{1D519}"), - ("Vopf", "\u{1D54D}"), - ("Vscr", "\u{1D4B1}"), - ("Vvdash", "\u{22AA}"), - ("Wcirc", "\u{0174}"), - ("Wedge", "\u{22C0}"), - ("Wfr", "\u{1D51A}"), - ("Wopf", "\u{1D54E}"), - ("Wscr", "\u{1D4B2}"), - ("Xfr", "\u{1D51B}"), - ("Xi", "\u{039E}"), - ("Xopf", "\u{1D54F}"), - ("Xscr", "\u{1D4B3}"), - ("YAcy", "\u{042F}"), - ("YIcy", "\u{0407}"), - ("YUcy", "\u{042E}"), - ("Yacute", "\u{00DD}"), - ("Ycirc", "\u{0176}"), - ("Ycy", "\u{042B}"), - ("Yfr", "\u{1D51C}"), - ("Yopf", "\u{1D550}"), - ("Yscr", "\u{1D4B4}"), - ("Yuml", "\u{0178}"), - ("ZHcy", "\u{0416}"), - ("Zacute", "\u{0179}"), - ("Zcaron", "\u{017D}"), - ("Zcy", "\u{0417}"), - ("Zdot", "\u{017B}"), - ("ZeroWidthSpace", "\u{200B}"), - ("Zeta", "\u{0396}"), - ("Zfr", "\u{2128}"), - ("Zopf", "\u{2124}"), - ("Zscr", "\u{1D4B5}"), - ("aacute", "\u{00E1}"), - ("abreve", "\u{0103}"), - ("ac", "\u{223E}"), - ("acE", "\u{223E}\u{0333}"), - ("acd", "\u{223F}"), - ("acirc", "\u{00E2}"), - ("acute", "\u{00B4}"), - ("acy", "\u{0430}"), - ("aelig", "\u{00E6}"), - ("af", "\u{2061}"), - ("afr", "\u{1D51E}"), - ("agrave", "\u{00E0}"), - ("alefsym", "\u{2135}"), - ("aleph", "\u{2135}"), - ("alpha", "\u{03B1}"), - ("amacr", "\u{0101}"), - ("amalg", "\u{2A3F}"), - ("amp", "&"), - ("and", "\u{2227}"), - ("andand", "\u{2A55}"), - ("andd", "\u{2A5C}"), - ("andslope", "\u{2A58}"), - ("andv", "\u{2A5A}"), - ("ang", "\u{2220}"), - ("ange", "\u{29A4}"), - ("angle", "\u{2220}"), - ("angmsd", "\u{2221}"), - ("angmsdaa", "\u{29A8}"), - ("angmsdab", "\u{29A9}"), - ("angmsdac", "\u{29AA}"), - ("angmsdad", "\u{29AB}"), - ("angmsdae", "\u{29AC}"), - ("angmsdaf", "\u{29AD}"), - ("angmsdag", "\u{29AE}"), - ("angmsdah", "\u{29AF}"), - ("angrt", "\u{221F}"), - ("angrtvb", "\u{22BE}"), - ("angrtvbd", "\u{299D}"), - ("angsph", "\u{2222}"), - ("angst", "\u{00C5}"), - ("angzarr", "\u{237C}"), - ("aogon", "\u{0105}"), - ("aopf", "\u{1D552}"), - ("ap", "\u{2248}"), - ("apE", "\u{2A70}"), - ("apacir", "\u{2A6F}"), - ("ape", "\u{224A}"), - ("apid", "\u{224B}"), - ("apos", "'"), - ("approx", "\u{2248}"), - ("approxeq", "\u{224A}"), - ("aring", "\u{00E5}"), - ("ascr", "\u{1D4B6}"), - ("ast", "\u{002A}"), - ("asymp", "\u{2248}"), - ("asympeq", "\u{224D}"), - ("atilde", "\u{00E3}"), - ("auml", "\u{00E4}"), - ("awconint", "\u{2233}"), - ("awint", "\u{2A11}"), - ("bNot", "\u{2AED}"), - ("backcong", "\u{224C}"), - ("backepsilon", "\u{03F6}"), - ("backprime", "\u{2035}"), - ("backsim", "\u{223D}"), - ("backsimeq", "\u{22CD}"), - ("barvee", "\u{22BD}"), - ("barwed", "\u{2305}"), - ("barwedge", "\u{2305}"), - ("bbrk", "\u{23B5}"), - ("bbrktbrk", "\u{23B6}"), - ("bcong", "\u{224C}"), - ("bcy", "\u{0431}"), - ("bdquo", "\u{201E}"), - ("becaus", "\u{2235}"), - ("because", "\u{2235}"), - ("bemptyv", "\u{29B0}"), - ("bepsi", "\u{03F6}"), - ("bernou", "\u{212C}"), - ("beta", "\u{03B2}"), - ("beth", "\u{2136}"), - ("between", "\u{226C}"), - ("bfr", "\u{1D51F}"), - ("bigcap", "\u{22C2}"), - ("bigcirc", "\u{25EF}"), - ("bigcup", "\u{22C3}"), - ("bigodot", "\u{2A00}"), - ("bigoplus", "\u{2A01}"), - ("bigotimes", "\u{2A02}"), - ("bigsqcup", "\u{2A06}"), - ("bigstar", "\u{2605}"), - ("bigtriangledown", "\u{25BD}"), - ("bigtriangleup", "\u{25B3}"), - ("biguplus", "\u{2A04}"), - ("bigvee", "\u{22C1}"), - ("bigwedge", "\u{22C0}"), - ("bkarow", "\u{290D}"), - ("blacklozenge", "\u{29EB}"), - ("blacksquare", "\u{25AA}"), - ("blacktriangle", "\u{25B4}"), - ("blacktriangledown", "\u{25BE}"), - ("blacktriangleleft", "\u{25C2}"), - ("blacktriangleright", "\u{25B8}"), - ("blank", "\u{2423}"), - ("blk12", "\u{2592}"), - ("blk14", "\u{2591}"), - ("blk34", "\u{2593}"), - ("block", "\u{2588}"), - ("bne", "\u{003D}\u{20E5}"), - ("bnequiv", "\u{2261}\u{20E5}"), - ("bnot", "\u{2310}"), - ("bopf", "\u{1D553}"), - ("bot", "\u{22A5}"), - ("bottom", "\u{22A5}"), - ("bowtie", "\u{22C8}"), - ("boxDL", "\u{2557}"), - ("boxDR", "\u{2554}"), - ("boxDl", "\u{2556}"), - ("boxDr", "\u{2553}"), - ("boxH", "\u{2550}"), - ("boxHD", "\u{2566}"), - ("boxHU", "\u{2569}"), - ("boxHd", "\u{2564}"), - ("boxHu", "\u{2567}"), - ("boxUL", "\u{255D}"), - ("boxUR", "\u{255A}"), - ("boxUl", "\u{255C}"), - ("boxUr", "\u{2559}"), - ("boxV", "\u{2551}"), - ("boxVH", "\u{256C}"), - ("boxVL", "\u{2563}"), - ("boxVR", "\u{2560}"), - ("boxVh", "\u{256B}"), - ("boxVl", "\u{2562}"), - ("boxVr", "\u{255F}"), - ("boxbox", "\u{29C9}"), - ("boxdL", "\u{2555}"), - ("boxdR", "\u{2552}"), - ("boxdl", "\u{2510}"), - ("boxdr", "\u{250C}"), - ("boxh", "\u{2500}"), - ("boxhD", "\u{2565}"), - ("boxhU", "\u{2568}"), - ("boxhd", "\u{252C}"), - ("boxhu", "\u{2534}"), - ("boxminus", "\u{229F}"), - ("boxplus", "\u{229E}"), - ("boxtimes", "\u{22A0}"), - ("boxuL", "\u{255B}"), - ("boxuR", "\u{2558}"), - ("boxul", "\u{2518}"), - ("boxur", "\u{2514}"), - ("boxv", "\u{2502}"), - ("boxvH", "\u{256A}"), - ("boxvL", "\u{2561}"), - ("boxvR", "\u{255E}"), - ("boxvh", "\u{253C}"), - ("boxvl", "\u{2524}"), - ("boxvr", "\u{251C}"), - ("bprime", "\u{2035}"), - ("breve", "\u{02D8}"), - ("brvbar", "\u{00A6}"), - ("bscr", "\u{1D4B7}"), - ("bsemi", "\u{204F}"), - ("bsim", "\u{223D}"), - ("bsime", "\u{22CD}"), - ("bsol", "\\"), - ("bsolb", "\u{29C5}"), - ("bsolhsub", "\u{27C8}"), - ("bull", "\u{2022}"), - ("bullet", "\u{2022}"), - ("bump", "\u{224E}"), - ("bumpE", "\u{2AAE}"), - ("bumpe", "\u{224F}"), - ("bumpeq", "\u{224F}"), - ("cacute", "\u{0107}"), - ("cap", "\u{2229}"), - ("capand", "\u{2A44}"), - ("capbrcup", "\u{2A49}"), - ("capcap", "\u{2A4B}"), - ("capcup", "\u{2A47}"), - ("capdot", "\u{2A40}"), - ("caps", "\u{2229}\u{FE00}"), - ("caret", "\u{2041}"), - ("caron", "\u{02C7}"), - ("ccaps", "\u{2A4D}"), - ("ccaron", "\u{010D}"), - ("ccedil", "\u{00E7}"), - ("ccirc", "\u{0109}"), - ("ccups", "\u{2A4C}"), - ("ccupssm", "\u{2A50}"), - ("cdot", "\u{010B}"), - ("cedil", "\u{00B8}"), - ("cemptyv", "\u{29B2}"), - ("cent", "\u{00A2}"), - ("centerdot", "\u{00B7}"), - ("cfr", "\u{1D520}"), - ("chcy", "\u{0447}"), - ("check", "\u{2713}"), - ("checkmark", "\u{2713}"), - ("chi", "\u{03C7}"), - ("cir", "\u{25CB}"), - ("cirE", "\u{29C3}"), - ("circ", "\u{02C6}"), - ("circeq", "\u{2257}"), - ("circlearrowleft", "\u{21BA}"), - ("circlearrowright", "\u{21BB}"), - ("circledR", "\u{00AE}"), - ("circledS", "\u{24C8}"), - ("circledast", "\u{229B}"), - ("circledcirc", "\u{229A}"), - ("circleddash", "\u{229D}"), - ("cire", "\u{2257}"), - ("cirfnint", "\u{2A10}"), - ("cirmid", "\u{2AEF}"), - ("cirscir", "\u{29C2}"), - ("clubs", "\u{2663}"), - ("clubsuit", "\u{2663}"), - ("colon", "\u{003A}"), - ("colone", "\u{2254}"), - ("coloneq", "\u{2254}"), - ("comma", "\u{002C}"), - ("commat", "\u{0040}"), - ("comp", "\u{2201}"), - ("compfn", "\u{2218}"), - ("complement", "\u{2201}"), - ("complexes", "\u{2102}"), - ("cong", "\u{2245}"), - ("congdot", "\u{2A6D}"), - ("conint", "\u{222E}"), - ("copf", "\u{1D554}"), - ("coprod", "\u{2210}"), - ("copy", "\u{00A9}"), - ("copysr", "\u{2117}"), - ("crarr", "\u{21B5}"), - ("cross", "\u{2717}"), - ("cscr", "\u{1D4B8}"), - ("csub", "\u{2ACF}"), - ("csube", "\u{2AD1}"), - ("csup", "\u{2AD0}"), - ("csupe", "\u{2AD2}"), - ("ctdot", "\u{22EF}"), - ("cudarrl", "\u{2938}"), - ("cudarrr", "\u{2935}"), - ("cuepr", "\u{22DE}"), - ("cuesc", "\u{22DF}"), - ("cularr", "\u{21B6}"), - ("cularrp", "\u{293D}"), - ("cup", "\u{222A}"), - ("cupbrcap", "\u{2A48}"), - ("cupcap", "\u{2A46}"), - ("cupcup", "\u{2A4A}"), - ("cupdot", "\u{228D}"), - ("cupor", "\u{2A45}"), - ("cups", "\u{222A}\u{FE00}"), - ("curarr", "\u{21B7}"), - ("curarrm", "\u{293C}"), - ("curlyeqprec", "\u{22DE}"), - ("curlyeqsucc", "\u{22DF}"), - ("curlyvee", "\u{22CE}"), - ("curlywedge", "\u{22CF}"), - ("curren", "\u{00A4}"), - ("curvearrowleft", "\u{21B6}"), - ("curvearrowright", "\u{21B7}"), - ("cuvee", "\u{22CE}"), - ("cuwed", "\u{22CF}"), - ("cwconint", "\u{2232}"), - ("cwint", "\u{2231}"), - ("cylcty", "\u{232D}"), - ("dArr", "\u{21D3}"), - ("dHar", "\u{2965}"), - ("dagger", "\u{2020}"), - ("daleth", "\u{2138}"), - ("darr", "\u{2193}"), - ("dash", "\u{2010}"), - ("dashv", "\u{22A3}"), - ("dbkarow", "\u{290F}"), - ("dblac", "\u{02DD}"), - ("dcaron", "\u{010F}"), - ("dcy", "\u{0434}"), - ("dd", "\u{2146}"), - ("ddagger", "\u{2021}"), - ("ddarr", "\u{21CA}"), - ("ddotseq", "\u{2A77}"), - ("deg", "\u{00B0}"), - ("delta", "\u{03B4}"), - ("demptyv", "\u{29B1}"), - ("dfisht", "\u{297F}"), - ("dfr", "\u{1D521}"), - ("dharl", "\u{21C3}"), - ("dharr", "\u{21C2}"), - ("diam", "\u{22C4}"), - ("diamond", "\u{22C4}"), - ("diamondsuit", "\u{2666}"), - ("diams", "\u{2666}"), - ("die", "\u{00A8}"), - ("digamma", "\u{03DD}"), - ("disin", "\u{22F2}"), - ("div", "\u{00F7}"), - ("divide", "\u{00F7}"), - ("divideontimes", "\u{22C7}"), - ("divonx", "\u{22C7}"), - ("djcy", "\u{0452}"), - ("dlcorn", "\u{231E}"), - ("dlcrop", "\u{230D}"), - ("dollar", "\u{0024}"), - ("dopf", "\u{1D555}"), - ("dot", "\u{02D9}"), - ("doteq", "\u{2250}"), - ("doteqdot", "\u{2251}"), - ("dotminus", "\u{2238}"), - ("dotplus", "\u{2214}"), - ("dotsquare", "\u{22A1}"), - ("doublebarwedge", "\u{2306}"), - ("downarrow", "\u{2193}"), - ("downdownarrows", "\u{21CA}"), - ("downharpoonleft", "\u{21C3}"), - ("downharpoonright", "\u{21C2}"), - ("drbkarow", "\u{2910}"), - ("drcorn", "\u{231F}"), - ("drcrop", "\u{230C}"), - ("dscr", "\u{1D4B9}"), - ("dscy", "\u{0455}"), - ("dsol", "\u{29F6}"), - ("dstrok", "\u{0111}"), - ("dtdot", "\u{22F1}"), - ("dtri", "\u{25BF}"), - ("dtrif", "\u{25BE}"), - ("duarr", "\u{21F5}"), - ("duhar", "\u{296F}"), - ("dwangle", "\u{29A6}"), - ("dzcy", "\u{045F}"), - ("dzigrarr", "\u{27FF}"), - ("eDDot", "\u{2A77}"), - ("eDot", "\u{2251}"), - ("eacute", "\u{00E9}"), - ("easter", "\u{2A6E}"), - ("ecaron", "\u{011B}"), - ("ecir", "\u{2256}"), - ("ecirc", "\u{00EA}"), - ("ecolon", "\u{2255}"), - ("ecy", "\u{044D}"), - ("edot", "\u{0117}"), - ("ee", "\u{2147}"), - ("efDot", "\u{2252}"), - ("efr", "\u{1D522}"), - ("eg", "\u{2A9A}"), - ("egrave", "\u{00E8}"), - ("egs", "\u{2A96}"), - ("egsdot", "\u{2A98}"), - ("el", "\u{2A99}"), - ("elinters", "\u{23E7}"), - ("ell", "\u{2113}"), - ("els", "\u{2A95}"), - ("elsdot", "\u{2A97}"), - ("emacr", "\u{0113}"), - ("empty", "\u{2205}"), - ("emptyset", "\u{2205}"), - ("emptyv", "\u{2205}"), - ("emsp", "\u{2003}"), - ("emsp13", "\u{2004}"), - ("emsp14", "\u{2005}"), - ("eng", "\u{014B}"), - ("ensp", "\u{2002}"), - ("eogon", "\u{0119}"), - ("eopf", "\u{1D556}"), - ("epar", "\u{22D5}"), - ("eparsl", "\u{29E3}"), - ("eplus", "\u{2A71}"), - ("epsi", "\u{03B5}"), - ("epsilon", "\u{03B5}"), - ("epsiv", "\u{03F5}"), - ("eqcirc", "\u{2256}"), - ("eqcolon", "\u{2255}"), - ("eqsim", "\u{2242}"), - ("eqslantgtr", "\u{2A96}"), - ("eqslantless", "\u{2A95}"), - ("equals", "\u{003D}"), - ("equest", "\u{225F}"), - ("equiv", "\u{2261}"), - ("equivDD", "\u{2A78}"), - ("eqvparsl", "\u{29E5}"), - ("erDot", "\u{2253}"), - ("erarr", "\u{2971}"), - ("escr", "\u{212F}"), - ("esdot", "\u{2250}"), - ("esim", "\u{2242}"), - ("eta", "\u{03B7}"), - ("eth", "\u{00F0}"), - ("euml", "\u{00EB}"), - ("euro", "\u{20AC}"), - ("excl", "\u{0021}"), - ("exist", "\u{2203}"), - ("expectation", "\u{2130}"), - ("exponentiale", "\u{2147}"), - ("fallingdotseq", "\u{2252}"), - ("fcy", "\u{0444}"), - ("female", "\u{2640}"), - ("ffilig", "\u{FB03}"), - ("fflig", "\u{FB00}"), - ("ffllig", "\u{FB04}"), - ("ffr", "\u{1D523}"), - ("filig", "\u{FB01}"), - ("fjlig", "\u{0066}\u{006A}"), - ("flat", "\u{266D}"), - ("fllig", "\u{FB02}"), - ("fltns", "\u{25B1}"), - ("fnof", "\u{0192}"), - ("fopf", "\u{1D557}"), - ("forall", "\u{2200}"), - ("fork", "\u{22D4}"), - ("forkv", "\u{2AD9}"), - ("fpartint", "\u{2A0D}"), - ("frac12", "\u{00BD}"), - ("frac13", "\u{2153}"), - ("frac14", "\u{00BC}"), - ("frac15", "\u{2155}"), - ("frac16", "\u{2159}"), - ("frac18", "\u{215B}"), - ("frac23", "\u{2154}"), - ("frac25", "\u{2156}"), - ("frac34", "\u{00BE}"), - ("frac35", "\u{2157}"), - ("frac38", "\u{215C}"), - ("frac45", "\u{2158}"), - ("frac56", "\u{215A}"), - ("frac58", "\u{215D}"), - ("frac78", "\u{215E}"), - ("frasl", "\u{2044}"), - ("frown", "\u{2322}"), - ("fscr", "\u{1D4BB}"), - ("gE", "\u{2267}"), - ("gEl", "\u{2A8C}"), - ("gacute", "\u{01F5}"), - ("gamma", "\u{03B3}"), - ("gammad", "\u{03DD}"), - ("gap", "\u{2A86}"), - ("gbreve", "\u{011F}"), - ("gcirc", "\u{011D}"), - ("gcy", "\u{0433}"), - ("gdot", "\u{0121}"), - ("ge", "\u{2265}"), - ("gel", "\u{22DB}"), - ("geq", "\u{2265}"), - ("geqq", "\u{2267}"), - ("geqslant", "\u{2A7E}"), - ("ges", "\u{2A7E}"), - ("gescc", "\u{2AA9}"), - ("gesdot", "\u{2A80}"), - ("gesdoto", "\u{2A82}"), - ("gesdotol", "\u{2A84}"), - ("gesl", "\u{22DB}\u{FE00}"), - ("gesles", "\u{2A94}"), - ("gfr", "\u{1D524}"), - ("gg", "\u{226B}"), - ("ggg", "\u{22D9}"), - ("gimel", "\u{2137}"), - ("gjcy", "\u{0453}"), - ("gl", "\u{2277}"), - ("glE", "\u{2A92}"), - ("gla", "\u{2AA5}"), - ("glj", "\u{2AA4}"), - ("gnE", "\u{2269}"), - ("gnap", "\u{2A8A}"), - ("gnapprox", "\u{2A8A}"), - ("gne", "\u{2A88}"), - ("gneq", "\u{2A88}"), - ("gneqq", "\u{2269}"), - ("gnsim", "\u{22E7}"), - ("gopf", "\u{1D558}"), - ("grave", "\u{0060}"), - ("gscr", "\u{210A}"), - ("gsim", "\u{2273}"), - ("gsime", "\u{2A8E}"), - ("gsiml", "\u{2A90}"), - ("gt", ">"), - ("gtcc", "\u{2AA7}"), - ("gtcir", "\u{2A7A}"), - ("gtdot", "\u{22D7}"), - ("gtlPar", "\u{2995}"), - ("gtquest", "\u{2A7C}"), - ("gtrapprox", "\u{2A86}"), - ("gtrarr", "\u{2978}"), - ("gtrdot", "\u{22D7}"), - ("gtreqless", "\u{22DB}"), - ("gtreqqless", "\u{2A8C}"), - ("gtrless", "\u{2277}"), - ("gtrsim", "\u{2273}"), - ("gvertneqq", "\u{2269}\u{FE00}"), - ("gvnE", "\u{2269}\u{FE00}"), - ("hArr", "\u{21D4}"), - ("hairsp", "\u{200A}"), - ("half", "\u{00BD}"), - ("hamilt", "\u{210B}"), - ("hardcy", "\u{044A}"), - ("harr", "\u{2194}"), - ("harrcir", "\u{2948}"), - ("harrw", "\u{21AD}"), - ("hbar", "\u{210F}"), - ("hcirc", "\u{0125}"), - ("hearts", "\u{2665}"), - ("heartsuit", "\u{2665}"), - ("hellip", "\u{2026}"), - ("hercon", "\u{22B9}"), - ("hfr", "\u{1D525}"), - ("hksearow", "\u{2925}"), - ("hkswarow", "\u{2926}"), - ("hoarr", "\u{21FF}"), - ("homtht", "\u{223B}"), - ("hookleftarrow", "\u{21A9}"), - ("hookrightarrow", "\u{21AA}"), - ("hopf", "\u{1D559}"), - ("horbar", "\u{2015}"), - ("hscr", "\u{1D4BD}"), - ("hslash", "\u{210F}"), - ("hstrok", "\u{0127}"), - ("hybull", "\u{2043}"), - ("hyphen", "\u{2010}"), - ("iacute", "\u{00ED}"), - ("ic", "\u{2063}"), - ("icirc", "\u{00EE}"), - ("icy", "\u{0438}"), - ("iecy", "\u{0435}"), - ("iexcl", "\u{00A1}"), - ("iff", "\u{21D4}"), - ("ifr", "\u{1D526}"), - ("igrave", "\u{00EC}"), - ("ii", "\u{2148}"), - ("iiiint", "\u{2A0C}"), - ("iiint", "\u{222D}"), - ("iinfin", "\u{29DC}"), - ("iiota", "\u{2129}"), - ("ijlig", "\u{0133}"), - ("imacr", "\u{012B}"), - ("image", "\u{2111}"), - ("imagline", "\u{2110}"), - ("imagpart", "\u{2111}"), - ("imath", "\u{0131}"), - ("imof", "\u{22B7}"), - ("imped", "\u{01B5}"), - ("in", "\u{2208}"), - ("incare", "\u{2105}"), - ("infin", "\u{221E}"), - ("infintie", "\u{29DD}"), - ("inodot", "\u{0131}"), - ("int", "\u{222B}"), - ("intcal", "\u{22BA}"), - ("integers", "\u{2124}"), - ("intercal", "\u{22BA}"), - ("intlarhk", "\u{2A17}"), - ("intprod", "\u{2A3C}"), - ("iocy", "\u{0451}"), - ("iogon", "\u{012F}"), - ("iopf", "\u{1D55A}"), - ("iota", "\u{03B9}"), - ("iprod", "\u{2A3C}"), - ("iquest", "\u{00BF}"), - ("iscr", "\u{1D4BE}"), - ("isin", "\u{2208}"), - ("isinE", "\u{22F9}"), - ("isindot", "\u{22F5}"), - ("isins", "\u{22F4}"), - ("isinsv", "\u{22F3}"), - ("isinv", "\u{2208}"), - ("it", "\u{2062}"), - ("itilde", "\u{0129}"), - ("iukcy", "\u{0456}"), - ("iuml", "\u{00EF}"), - ("jcirc", "\u{0135}"), - ("jcy", "\u{0439}"), - ("jfr", "\u{1D527}"), - ("jmath", "\u{0237}"), - ("jopf", "\u{1D55B}"), - ("jscr", "\u{1D4BF}"), - ("jsercy", "\u{0458}"), - ("jukcy", "\u{0454}"), - ("kappa", "\u{03BA}"), - ("kappav", "\u{03F0}"), - ("kcedil", "\u{0137}"), - ("kcy", "\u{043A}"), - ("kfr", "\u{1D528}"), - ("kgreen", "\u{0138}"), - ("khcy", "\u{0445}"), - ("kjcy", "\u{045C}"), - ("kopf", "\u{1D55C}"), - ("kscr", "\u{1D4C0}"), - ("lAarr", "\u{21DA}"), - ("lArr", "\u{21D0}"), - ("lAtail", "\u{291B}"), - ("lBarr", "\u{290E}"), - ("lE", "\u{2266}"), - ("lEg", "\u{2A8B}"), - ("lHar", "\u{2962}"), - ("lacute", "\u{013A}"), - ("laemptyv", "\u{29B4}"), - ("lagran", "\u{2112}"), - ("lambda", "\u{03BB}"), - ("lang", "\u{27E8}"), - ("langd", "\u{2991}"), - ("langle", "\u{27E8}"), - ("lap", "\u{2A85}"), - ("laquo", "\u{00AB}"), - ("larr", "\u{2190}"), - ("larrb", "\u{21E4}"), - ("larrbfs", "\u{291F}"), - ("larrfs", "\u{291D}"), - ("larrhk", "\u{21A9}"), - ("larrlp", "\u{21AB}"), - ("larrpl", "\u{2939}"), - ("larrsim", "\u{2973}"), - ("larrtl", "\u{21A2}"), - ("lat", "\u{2AAB}"), - ("latail", "\u{2919}"), - ("late", "\u{2AAD}"), - ("lates", "\u{2AAD}\u{FE00}"), - ("lbarr", "\u{290C}"), - ("lbbrk", "\u{2772}"), - ("lbrace", "\u{007B}"), - ("lbrack", "\u{005B}"), - ("lbrke", "\u{298B}"), - ("lbrksld", "\u{298F}"), - ("lbrkslu", "\u{298D}"), - ("lcaron", "\u{013E}"), - ("lcedil", "\u{013C}"), - ("lceil", "\u{2308}"), - ("lcub", "\u{007B}"), - ("lcy", "\u{043B}"), - ("ldca", "\u{2936}"), - ("ldquo", "\u{201C}"), - ("ldquor", "\u{201E}"), - ("ldrdhar", "\u{2967}"), - ("ldrushar", "\u{294B}"), - ("ldsh", "\u{21B2}"), - ("le", "\u{2264}"), - ("leftarrow", "\u{2190}"), - ("leftarrowtail", "\u{21A2}"), - ("leftharpoondown", "\u{21BD}"), - ("leftharpoonup", "\u{21BC}"), - ("leftleftarrows", "\u{21C7}"), - ("leftrightarrow", "\u{2194}"), - ("leftrightarrows", "\u{21C6}"), - ("leftrightharpoons", "\u{21CB}"), - ("leftrightsquigarrow", "\u{21AD}"), - ("leftthreetimes", "\u{22CB}"), - ("leg", "\u{22DA}"), - ("leq", "\u{2264}"), - ("leqq", "\u{2266}"), - ("leqslant", "\u{2A7D}"), - ("les", "\u{2A7D}"), - ("lescc", "\u{2AA8}"), - ("lesdot", "\u{2A7F}"), - ("lesdoto", "\u{2A81}"), - ("lesdotor", "\u{2A83}"), - ("lesg", "\u{22DA}\u{FE00}"), - ("lesges", "\u{2A93}"), - ("lessapprox", "\u{2A85}"), - ("lessdot", "\u{22D6}"), - ("lesseqgtr", "\u{22DA}"), - ("lesseqqgtr", "\u{2A8B}"), - ("lessgtr", "\u{2276}"), - ("lesssim", "\u{2272}"), - ("lfisht", "\u{297C}"), - ("lfloor", "\u{230A}"), - ("lfr", "\u{1D529}"), - ("lg", "\u{2276}"), - ("lgE", "\u{2A91}"), - ("lhard", "\u{21BD}"), - ("lharu", "\u{21BC}"), - ("lharul", "\u{296A}"), - ("lhblk", "\u{2584}"), - ("ljcy", "\u{0459}"), - ("ll", "\u{226A}"), - ("llarr", "\u{21C7}"), - ("llcorner", "\u{231E}"), - ("llhard", "\u{296B}"), - ("lltri", "\u{25FA}"), - ("lmidot", "\u{0140}"), - ("lmoust", "\u{23B0}"), - ("lmoustache", "\u{23B0}"), - ("lnE", "\u{2268}"), - ("lnap", "\u{2A89}"), - ("lnapprox", "\u{2A89}"), - ("lne", "\u{2A87}"), - ("lneq", "\u{2A87}"), - ("lneqq", "\u{2268}"), - ("lnsim", "\u{22E6}"), - ("loang", "\u{27EC}"), - ("loarr", "\u{21FD}"), - ("lobrk", "\u{27E6}"), - ("longleftarrow", "\u{27F5}"), - ("longleftrightarrow", "\u{27F7}"), - ("longmapsto", "\u{27FC}"), - ("longrightarrow", "\u{27F6}"), - ("looparrowleft", "\u{21AB}"), - ("looparrowright", "\u{21AC}"), - ("lopar", "\u{2985}"), - ("lopf", "\u{1D55D}"), - ("loplus", "\u{2A2D}"), - ("lotimes", "\u{2A34}"), - ("lowast", "\u{2217}"), - ("lowbar", "\u{005F}"), - ("loz", "\u{25CA}"), - ("lozenge", "\u{25CA}"), - ("lozf", "\u{29EB}"), - ("lpar", "\u{0028}"), - ("lparlt", "\u{2993}"), - ("lrarr", "\u{21C6}"), - ("lrcorner", "\u{231F}"), - ("lrhar", "\u{21CB}"), - ("lrhard", "\u{296D}"), - ("lrm", "\u{200E}"), - ("lrtri", "\u{22BF}"), - ("lsaquo", "\u{2039}"), - ("lscr", "\u{1D4C1}"), - ("lsh", "\u{21B0}"), - ("lsim", "\u{2272}"), - ("lsime", "\u{2A8D}"), - ("lsimg", "\u{2A8F}"), - ("lsqb", "\u{005B}"), - ("lsquo", "\u{2018}"), - ("lsquor", "\u{201A}"), - ("lstrok", "\u{0142}"), - ("lt", "<"), - ("ltcc", "\u{2AA6}"), - ("ltcir", "\u{2A79}"), - ("ltdot", "\u{22D6}"), - ("lthree", "\u{22CB}"), - ("ltimes", "\u{22C9}"), - ("ltlarr", "\u{2976}"), - ("ltquest", "\u{2A7B}"), - ("ltrPar", "\u{2996}"), - ("ltri", "\u{25C3}"), - ("ltrie", "\u{22B4}"), - ("ltrif", "\u{25C2}"), - ("lurdshar", "\u{294A}"), - ("luruhar", "\u{2966}"), - ("lvertneqq", "\u{2268}\u{FE00}"), - ("lvnE", "\u{2268}\u{FE00}"), - ("mDDot", "\u{223A}"), - ("macr", "\u{00AF}"), - ("male", "\u{2642}"), - ("malt", "\u{2720}"), - ("maltese", "\u{2720}"), - ("map", "\u{21A6}"), - ("mapsto", "\u{21A6}"), - ("mapstodown", "\u{21A7}"), - ("mapstoleft", "\u{21A4}"), - ("mapstoup", "\u{21A5}"), - ("marker", "\u{25AE}"), - ("mcomma", "\u{2A29}"), - ("mcy", "\u{043C}"), - ("mdash", "\u{2014}"), - ("measuredangle", "\u{2221}"), - ("mfr", "\u{1D52A}"), - ("mho", "\u{2127}"), - ("micro", "\u{00B5}"), - ("mid", "\u{2223}"), - ("midast", "\u{002A}"), - ("midcir", "\u{2AF0}"), - ("middot", "\u{00B7}"), - ("minus", "\u{2212}"), - ("minusb", "\u{229F}"), - ("minusd", "\u{2238}"), - ("minusdu", "\u{2A2A}"), - ("mlcp", "\u{2ADB}"), - ("mldr", "\u{2026}"), - ("mnplus", "\u{2213}"), - ("models", "\u{22A7}"), - ("mopf", "\u{1D55E}"), - ("mp", "\u{2213}"), - ("mscr", "\u{1D4C2}"), - ("mstpos", "\u{223E}"), - ("mu", "\u{03BC}"), - ("multimap", "\u{22B8}"), - ("mumap", "\u{22B8}"), - ("nGg", "\u{22D9}\u{0338}"), - ("nGt", "\u{226B}\u{20D2}"), - ("nGtv", "\u{226B}\u{0338}"), - ("nLeftarrow", "\u{21CD}"), - ("nLeftrightarrow", "\u{21CE}"), - ("nLl", "\u{22D8}\u{0338}"), - ("nLt", "\u{226A}\u{20D2}"), - ("nLtv", "\u{226A}\u{0338}"), - ("nRightarrow", "\u{21CF}"), - ("nVDash", "\u{22AF}"), - ("nVdash", "\u{22AE}"), - ("nabla", "\u{2207}"), - ("nacute", "\u{0144}"), - ("nang", "\u{2220}\u{20D2}"), - ("nap", "\u{2249}"), - ("napE", "\u{2A70}\u{0338}"), - ("napid", "\u{224B}\u{0338}"), - ("napos", "\u{0149}"), - ("napprox", "\u{2249}"), - ("natur", "\u{266E}"), - ("natural", "\u{266E}"), - ("naturals", "\u{2115}"), - ("nbsp", "\u{00A0}"), - ("nbump", "\u{224E}\u{0338}"), - ("nbumpe", "\u{224F}\u{0338}"), - ("ncap", "\u{2A43}"), - ("ncaron", "\u{0148}"), - ("ncedil", "\u{0146}"), - ("ncong", "\u{2247}"), - ("ncongdot", "\u{2A6D}\u{0338}"), - ("ncup", "\u{2A42}"), - ("ncy", "\u{043D}"), - ("ndash", "\u{2013}"), - ("ne", "\u{2260}"), - ("neArr", "\u{21D7}"), - ("nearhk", "\u{2924}"), - ("nearr", "\u{2197}"), - ("nearrow", "\u{2197}"), - ("nedot", "\u{2250}\u{0338}"), - ("nequiv", "\u{2262}"), - ("nesear", "\u{2928}"), - ("nesim", "\u{2242}\u{0338}"), - ("nexist", "\u{2204}"), - ("nexists", "\u{2204}"), - ("nfr", "\u{1D52B}"), - ("ngE", "\u{2267}\u{0338}"), - ("nge", "\u{2271}"), - ("ngeq", "\u{2271}"), - ("ngeqq", "\u{2267}\u{0338}"), - ("ngeqslant", "\u{2A7E}\u{0338}"), - ("nges", "\u{2A7E}\u{0338}"), - ("ngsim", "\u{2275}"), - ("ngt", "\u{226F}"), - ("ngtr", "\u{226F}"), - ("nhArr", "\u{21CE}"), - ("nharr", "\u{21AE}"), - ("nhpar", "\u{2AF2}"), - ("ni", "\u{220B}"), - ("nis", "\u{22FC}"), - ("nisd", "\u{22FA}"), - ("niv", "\u{220B}"), - ("njcy", "\u{045A}"), - ("nlArr", "\u{21CD}"), - ("nlE", "\u{2266}\u{0338}"), - ("nlarr", "\u{219A}"), - ("nldr", "\u{2025}"), - ("nle", "\u{2270}"), - ("nleftarrow", "\u{219A}"), - ("nleftrightarrow", "\u{21AE}"), - ("nleq", "\u{2270}"), - ("nleqq", "\u{2266}\u{0338}"), - ("nleqslant", "\u{2A7D}\u{0338}"), - ("nles", "\u{2A7D}\u{0338}"), - ("nless", "\u{226E}"), - ("nlsim", "\u{2274}"), - ("nlt", "\u{226E}"), - ("nltri", "\u{22EA}"), - ("nltrie", "\u{22EC}"), - ("nmid", "\u{2224}"), - ("nopf", "\u{1D55F}"), - ("not", "\u{00AC}"), - ("notin", "\u{2209}"), - ("notinE", "\u{22F9}\u{0338}"), - ("notindot", "\u{22F5}\u{0338}"), - ("notinva", "\u{2209}"), - ("notinvb", "\u{22F7}"), - ("notinvc", "\u{22F6}"), - ("notni", "\u{220C}"), - ("notniva", "\u{220C}"), - ("notnivb", "\u{22FE}"), - ("notnivc", "\u{22FD}"), - ("npar", "\u{2226}"), - ("nparallel", "\u{2226}"), - ("nparsl", "\u{2AFD}\u{20E5}"), - ("npart", "\u{2202}\u{0338}"), - ("npolint", "\u{2A14}"), - ("npr", "\u{2280}"), - ("nprcue", "\u{22E0}"), - ("npre", "\u{2AAF}\u{0338}"), - ("nprec", "\u{2280}"), - ("npreceq", "\u{2AAF}\u{0338}"), - ("nrArr", "\u{21CF}"), - ("nrarr", "\u{219B}"), - ("nrarrc", "\u{2933}\u{0338}"), - ("nrarrw", "\u{219D}\u{0338}"), - ("nrightarrow", "\u{219B}"), - ("nrtri", "\u{22EB}"), - ("nrtrie", "\u{22ED}"), - ("nsc", "\u{2281}"), - ("nsccue", "\u{22E1}"), - ("nsce", "\u{2AB0}\u{0338}"), - ("nscr", "\u{1D4C3}"), - ("nshortmid", "\u{2224}"), - ("nshortparallel", "\u{2226}"), - ("nsim", "\u{2241}"), - ("nsime", "\u{2244}"), - ("nsimeq", "\u{2244}"), - ("nsmid", "\u{2224}"), - ("nspar", "\u{2226}"), - ("nsqsube", "\u{22E2}"), - ("nsqsupe", "\u{22E3}"), - ("nsub", "\u{2284}"), - ("nsubE", "\u{2AC5}\u{0338}"), - ("nsube", "\u{2288}"), - ("nsubset", "\u{2282}\u{20D2}"), - ("nsubseteq", "\u{2288}"), - ("nsubseteqq", "\u{2AC5}\u{0338}"), - ("nsucc", "\u{2281}"), - ("nsucceq", "\u{2AB0}\u{0338}"), - ("nsup", "\u{2285}"), - ("nsupE", "\u{2AC6}\u{0338}"), - ("nsupe", "\u{2289}"), - ("nsupset", "\u{2283}\u{20D2}"), - ("nsupseteq", "\u{2289}"), - ("nsupseteqq", "\u{2AC6}\u{0338}"), - ("ntgl", "\u{2279}"), - ("ntilde", "\u{00F1}"), - ("ntlg", "\u{2278}"), - ("ntriangleleft", "\u{22EA}"), - ("ntrianglelefteq", "\u{22EC}"), - ("ntriangleright", "\u{22EB}"), - ("ntrianglerighteq", "\u{22ED}"), - ("nu", "\u{03BD}"), - ("num", "\u{0023}"), - ("numero", "\u{2116}"), - ("numsp", "\u{2007}"), - ("nvDash", "\u{22AD}"), - ("nvHarr", "\u{2904}"), - ("nvap", "\u{224D}\u{20D2}"), - ("nvdash", "\u{22AC}"), - ("nvge", "\u{2265}\u{20D2}"), - ("nvgt", ">\u{20D2}"), - ("nvinfin", "\u{29DE}"), - ("nvlArr", "\u{2902}"), - ("nvle", "\u{2264}\u{20D2}"), - ("nvlt", "<\u{20D2}"), - ("nvltrie", "\u{22B4}\u{20D2}"), - ("nvrArr", "\u{2903}"), - ("nvrtrie", "\u{22B5}\u{20D2}"), - ("nvsim", "\u{223C}\u{20D2}"), - ("nwArr", "\u{21D6}"), - ("nwarhk", "\u{2923}"), - ("nwarr", "\u{2196}"), - ("nwarrow", "\u{2196}"), - ("nwnear", "\u{2927}"), - ("oS", "\u{24C8}"), - ("oacute", "\u{00F3}"), - ("oast", "\u{229B}"), - ("ocir", "\u{229A}"), - ("ocirc", "\u{00F4}"), - ("ocy", "\u{043E}"), - ("odash", "\u{229D}"), - ("odblac", "\u{0151}"), - ("odiv", "\u{2A38}"), - ("odot", "\u{2299}"), - ("odsold", "\u{29BC}"), - ("oelig", "\u{0153}"), - ("ofcir", "\u{29BF}"), - ("ofr", "\u{1D52C}"), - ("ogon", "\u{02DB}"), - ("ograve", "\u{00F2}"), - ("ogt", "\u{29C1}"), - ("ohbar", "\u{29B5}"), - ("ohm", "\u{03A9}"), - ("oint", "\u{222E}"), - ("olarr", "\u{21BA}"), - ("olcir", "\u{29BE}"), - ("olcross", "\u{29BB}"), - ("oline", "\u{203E}"), - ("olt", "\u{29C0}"), - ("omacr", "\u{014D}"), - ("omega", "\u{03C9}"), - ("omicron", "\u{03BF}"), - ("omid", "\u{29B6}"), - ("ominus", "\u{2296}"), - ("oopf", "\u{1D560}"), - ("opar", "\u{29B7}"), - ("operp", "\u{29B9}"), - ("oplus", "\u{2295}"), - ("or", "\u{2228}"), - ("orarr", "\u{21BB}"), - ("ord", "\u{2A5D}"), - ("order", "\u{2134}"), - ("orderof", "\u{2134}"), - ("ordf", "\u{00AA}"), - ("ordm", "\u{00BA}"), - ("origof", "\u{22B6}"), - ("oror", "\u{2A56}"), - ("orslope", "\u{2A57}"), - ("orv", "\u{2A5B}"), - ("oscr", "\u{2134}"), - ("oslash", "\u{00F8}"), - ("osol", "\u{2298}"), - ("otilde", "\u{00F5}"), - ("otimes", "\u{2297}"), - ("otimesas", "\u{2A36}"), - ("ouml", "\u{00F6}"), - ("ovbar", "\u{233D}"), - ("par", "\u{2225}"), - ("para", "\u{00B6}"), - ("parallel", "\u{2225}"), - ("parsim", "\u{2AF3}"), - ("parsl", "\u{2AFD}"), - ("part", "\u{2202}"), - ("pcy", "\u{043F}"), - ("percnt", "\u{0025}"), - ("period", "\u{002E}"), - ("permil", "\u{2030}"), - ("perp", "\u{22A5}"), - ("pertenk", "\u{2031}"), - ("pfr", "\u{1D52D}"), - ("phi", "\u{03C6}"), - ("phiv", "\u{03D5}"), - ("phmmat", "\u{2133}"), - ("phone", "\u{260E}"), - ("pi", "\u{03C0}"), - ("pitchfork", "\u{22D4}"), - ("piv", "\u{03D6}"), - ("planck", "\u{210F}"), - ("planckh", "\u{210E}"), - ("plankv", "\u{210F}"), - ("plus", "\u{002B}"), - ("plusacir", "\u{2A23}"), - ("plusb", "\u{229E}"), - ("pluscir", "\u{2A22}"), - ("plusdo", "\u{2214}"), - ("plusdu", "\u{2A25}"), - ("pluse", "\u{2A72}"), - ("plusmn", "\u{00B1}"), - ("plussim", "\u{2A26}"), - ("plustwo", "\u{2A27}"), - ("pm", "\u{00B1}"), - ("pointint", "\u{2A15}"), - ("popf", "\u{1D561}"), - ("pound", "\u{00A3}"), - ("pr", "\u{227A}"), - ("prE", "\u{2AB3}"), - ("prap", "\u{2AB7}"), - ("prcue", "\u{227C}"), - ("pre", "\u{2AAF}"), - ("prec", "\u{227A}"), - ("precapprox", "\u{2AB7}"), - ("preccurlyeq", "\u{227C}"), - ("preceq", "\u{2AAF}"), - ("precnapprox", "\u{2AB9}"), - ("precneqq", "\u{2AB5}"), - ("precnsim", "\u{22E8}"), - ("precsim", "\u{227E}"), - ("prime", "\u{2032}"), - ("primes", "\u{2119}"), - ("prnE", "\u{2AB5}"), - ("prnap", "\u{2AB9}"), - ("prnsim", "\u{22E8}"), - ("prod", "\u{220F}"), - ("profalar", "\u{232E}"), - ("profline", "\u{2312}"), - ("profsurf", "\u{2313}"), - ("prop", "\u{221D}"), - ("propto", "\u{221D}"), - ("prsim", "\u{227E}"), - ("prurel", "\u{22B0}"), - ("pscr", "\u{1D4C5}"), - ("psi", "\u{03C8}"), - ("puncsp", "\u{2008}"), - ("qfr", "\u{1D52E}"), - ("qint", "\u{2A0C}"), - ("qopf", "\u{1D562}"), - ("qprime", "\u{2057}"), - ("qscr", "\u{1D4C6}"), - ("quaternions", "\u{210D}"), - ("quatint", "\u{2A16}"), - ("quest", "\u{003F}"), - ("questeq", "\u{225F}"), - ("quot", "\""), - ("rAarr", "\u{21DB}"), - ("rArr", "\u{21D2}"), - ("rAtail", "\u{291C}"), - ("rBarr", "\u{290F}"), - ("rHar", "\u{2964}"), - ("race", "\u{223D}\u{0331}"), - ("racute", "\u{0155}"), - ("radic", "\u{221A}"), - ("raemptyv", "\u{29B3}"), - ("rang", "\u{27E9}"), - ("rangd", "\u{2992}"), - ("range", "\u{29A5}"), - ("rangle", "\u{27E9}"), - ("raquo", "\u{00BB}"), - ("rarr", "\u{2192}"), - ("rarrap", "\u{2975}"), - ("rarrb", "\u{21E5}"), - ("rarrbfs", "\u{2920}"), - ("rarrc", "\u{2933}"), - ("rarrfs", "\u{291E}"), - ("rarrhk", "\u{21AA}"), - ("rarrlp", "\u{21AC}"), - ("rarrpl", "\u{2945}"), - ("rarrsim", "\u{2974}"), - ("rarrtl", "\u{21A3}"), - ("rarrw", "\u{219D}"), - ("ratail", "\u{291A}"), - ("ratio", "\u{2236}"), - ("rationals", "\u{211A}"), - ("rbarr", "\u{290D}"), - ("rbbrk", "\u{2773}"), - ("rbrace", "\u{007D}"), - ("rbrack", "\u{005D}"), - ("rbrke", "\u{298C}"), - ("rbrksld", "\u{298E}"), - ("rbrkslu", "\u{2990}"), - ("rcaron", "\u{0159}"), - ("rcedil", "\u{0157}"), - ("rceil", "\u{2309}"), - ("rcub", "\u{007D}"), - ("rcy", "\u{0440}"), - ("rdca", "\u{2937}"), - ("rdldhar", "\u{2969}"), - ("rdquo", "\u{201D}"), - ("rdquor", "\u{201D}"), - ("rdsh", "\u{21B3}"), - ("real", "\u{211C}"), - ("realine", "\u{211B}"), - ("realpart", "\u{211C}"), - ("reals", "\u{211D}"), - ("rect", "\u{25AD}"), - ("reg", "\u{00AE}"), - ("rfisht", "\u{297D}"), - ("rfloor", "\u{230B}"), - ("rfr", "\u{1D52F}"), - ("rhard", "\u{21C1}"), - ("rharu", "\u{21C0}"), - ("rharul", "\u{296C}"), - ("rho", "\u{03C1}"), - ("rhov", "\u{03F1}"), - ("rightarrow", "\u{2192}"), - ("rightarrowtail", "\u{21A3}"), - ("rightharpoondown", "\u{21C1}"), - ("rightharpoonup", "\u{21C0}"), - ("rightleftarrows", "\u{21C4}"), - ("rightleftharpoons", "\u{21CC}"), - ("rightrightarrows", "\u{21C9}"), - ("rightsquigarrow", "\u{219D}"), - ("rightthreetimes", "\u{22CC}"), - ("ring", "\u{02DA}"), - ("risingdotseq", "\u{2253}"), - ("rlarr", "\u{21C4}"), - ("rlhar", "\u{21CC}"), - ("rlm", "\u{200F}"), - ("rmoust", "\u{23B1}"), - ("rmoustache", "\u{23B1}"), - ("rnmid", "\u{2AEE}"), - ("roang", "\u{27ED}"), - ("roarr", "\u{21FE}"), - ("robrk", "\u{27E7}"), - ("ropar", "\u{2986}"), - ("ropf", "\u{1D563}"), - ("roplus", "\u{2A2E}"), - ("rotimes", "\u{2A35}"), - ("rpar", "\u{0029}"), - ("rpargt", "\u{2994}"), - ("rppolint", "\u{2A12}"), - ("rrarr", "\u{21C9}"), - ("rsaquo", "\u{203A}"), - ("rscr", "\u{1D4C7}"), - ("rsh", "\u{21B1}"), - ("rsqb", "\u{005D}"), - ("rsquo", "\u{2019}"), - ("rsquor", "\u{2019}"), - ("rthree", "\u{22CC}"), - ("rtimes", "\u{22CA}"), - ("rtri", "\u{25B9}"), - ("rtrie", "\u{22B5}"), - ("rtrif", "\u{25B8}"), - ("rtriltri", "\u{29CE}"), - ("ruluhar", "\u{2968}"), - ("rx", "\u{211E}"), - ("sacute", "\u{015B}"), - ("sbquo", "\u{201A}"), - ("sc", "\u{227B}"), - ("scE", "\u{2AB4}"), - ("scap", "\u{2AB8}"), - ("scaron", "\u{0161}"), - ("sccue", "\u{227D}"), - ("sce", "\u{2AB0}"), - ("scedil", "\u{015F}"), - ("scirc", "\u{015D}"), - ("scnE", "\u{2AB6}"), - ("scnap", "\u{2ABA}"), - ("scnsim", "\u{22E9}"), - ("scpolint", "\u{2A13}"), - ("scsim", "\u{227F}"), - ("scy", "\u{0441}"), - ("sdot", "\u{22C5}"), - ("sdotb", "\u{22A1}"), - ("sdote", "\u{2A66}"), - ("seArr", "\u{21D8}"), - ("searhk", "\u{2925}"), - ("searr", "\u{2198}"), - ("searrow", "\u{2198}"), - ("sect", "\u{00A7}"), - ("semi", "\u{003B}"), - ("seswar", "\u{2929}"), - ("setminus", "\u{2216}"), - ("setmn", "\u{2216}"), - ("sext", "\u{2736}"), - ("sfr", "\u{1D530}"), - ("sfrown", "\u{2322}"), - ("sharp", "\u{266F}"), - ("shchcy", "\u{0449}"), - ("shcy", "\u{0448}"), - ("shortmid", "\u{2223}"), - ("shortparallel", "\u{2225}"), - ("shy", "\u{00AD}"), - ("sigma", "\u{03C3}"), - ("sigmaf", "\u{03C2}"), - ("sigmav", "\u{03C2}"), - ("sim", "\u{223C}"), - ("simdot", "\u{2A6A}"), - ("sime", "\u{2243}"), - ("simeq", "\u{2243}"), - ("simg", "\u{2A9E}"), - ("simgE", "\u{2AA0}"), - ("siml", "\u{2A9D}"), - ("simlE", "\u{2A9F}"), - ("simne", "\u{2246}"), - ("simplus", "\u{2A24}"), - ("simrarr", "\u{2972}"), - ("slarr", "\u{2190}"), - ("smallsetminus", "\u{2216}"), - ("smashp", "\u{2A33}"), - ("smeparsl", "\u{29E4}"), - ("smid", "\u{2223}"), - ("smile", "\u{2323}"), - ("smt", "\u{2AAA}"), - ("smte", "\u{2AAC}"), - ("smtes", "\u{2AAC}\u{FE00}"), - ("softcy", "\u{044C}"), - ("sol", "\u{002F}"), - ("solb", "\u{29C4}"), - ("solbar", "\u{233F}"), - ("sopf", "\u{1D564}"), - ("spades", "\u{2660}"), - ("spadesuit", "\u{2660}"), - ("spar", "\u{2225}"), - ("sqcap", "\u{2293}"), - ("sqcaps", "\u{2293}\u{FE00}"), - ("sqcup", "\u{2294}"), - ("sqcups", "\u{2294}\u{FE00}"), - ("sqsub", "\u{228F}"), - ("sqsube", "\u{2291}"), - ("sqsubset", "\u{228F}"), - ("sqsubseteq", "\u{2291}"), - ("sqsup", "\u{2290}"), - ("sqsupe", "\u{2292}"), - ("sqsupset", "\u{2290}"), - ("sqsupseteq", "\u{2292}"), - ("squ", "\u{25A1}"), - ("square", "\u{25A1}"), - ("squarf", "\u{25AA}"), - ("squf", "\u{25AA}"), - ("srarr", "\u{2192}"), - ("sscr", "\u{1D4C8}"), - ("ssetmn", "\u{2216}"), - ("ssmile", "\u{2323}"), - ("sstarf", "\u{22C6}"), - ("star", "\u{2606}"), - ("starf", "\u{2605}"), - ("straightepsilon", "\u{03F5}"), - ("straightphi", "\u{03D5}"), - ("strns", "\u{00AF}"), - ("sub", "\u{2282}"), - ("subE", "\u{2AC5}"), - ("subdot", "\u{2ABD}"), - ("sube", "\u{2286}"), - ("subedot", "\u{2AC3}"), - ("submult", "\u{2AC1}"), - ("subnE", "\u{2ACB}"), - ("subne", "\u{228A}"), - ("subplus", "\u{2ABF}"), - ("subrarr", "\u{2979}"), - ("subset", "\u{2282}"), - ("subseteq", "\u{2286}"), - ("subseteqq", "\u{2AC5}"), - ("subsetneq", "\u{228A}"), - ("subsetneqq", "\u{2ACB}"), - ("subsim", "\u{2AC7}"), - ("subsub", "\u{2AD5}"), - ("subsup", "\u{2AD3}"), - ("succ", "\u{227B}"), - ("succapprox", "\u{2AB8}"), - ("succcurlyeq", "\u{227D}"), - ("succeq", "\u{2AB0}"), - ("succnapprox", "\u{2ABA}"), - ("succneqq", "\u{2AB6}"), - ("succnsim", "\u{22E9}"), - ("succsim", "\u{227F}"), - ("sum", "\u{2211}"), - ("sung", "\u{266A}"), - ("sup", "\u{2283}"), - ("sup1", "\u{00B9}"), - ("sup2", "\u{00B2}"), - ("sup3", "\u{00B3}"), - ("supE", "\u{2AC6}"), - ("supdot", "\u{2ABE}"), - ("supdsub", "\u{2AD8}"), - ("supe", "\u{2287}"), - ("supedot", "\u{2AC4}"), - ("suphsol", "\u{27C9}"), - ("suphsub", "\u{2AD7}"), - ("suplarr", "\u{297B}"), - ("supmult", "\u{2AC2}"), - ("supnE", "\u{2ACC}"), - ("supne", "\u{228B}"), - ("supplus", "\u{2AC0}"), - ("supset", "\u{2283}"), - ("supseteq", "\u{2287}"), - ("supseteqq", "\u{2AC6}"), - ("supsetneq", "\u{228B}"), - ("supsetneqq", "\u{2ACC}"), - ("supsim", "\u{2AC8}"), - ("supsub", "\u{2AD4}"), - ("supsup", "\u{2AD6}"), - ("swArr", "\u{21D9}"), - ("swarhk", "\u{2926}"), - ("swarr", "\u{2199}"), - ("swarrow", "\u{2199}"), - ("swnwar", "\u{292A}"), - ("szlig", "\u{00DF}"), - ("target", "\u{2316}"), - ("tau", "\u{03C4}"), - ("tbrk", "\u{23B4}"), - ("tcaron", "\u{0165}"), - ("tcedil", "\u{0163}"), - ("tcy", "\u{0442}"), - ("tdot", "\u{20DB}"), - ("telrec", "\u{2315}"), - ("tfr", "\u{1D531}"), - ("there4", "\u{2234}"), - ("therefore", "\u{2234}"), - ("theta", "\u{03B8}"), - ("thetasym", "\u{03D1}"), - ("thetav", "\u{03D1}"), - ("thickapprox", "\u{2248}"), - ("thicksim", "\u{223C}"), - ("thinsp", "\u{2009}"), - ("thkap", "\u{2248}"), - ("thksim", "\u{223C}"), - ("thorn", "\u{00FE}"), - ("tilde", "\u{02DC}"), - ("times", "\u{00D7}"), - ("timesb", "\u{22A0}"), - ("timesbar", "\u{2A31}"), - ("timesd", "\u{2A30}"), - ("tint", "\u{222D}"), - ("toea", "\u{2928}"), - ("top", "\u{22A4}"), - ("topbot", "\u{2336}"), - ("topcir", "\u{2AF1}"), - ("topf", "\u{1D565}"), - ("topfork", "\u{2ADA}"), - ("tosa", "\u{2929}"), - ("tprime", "\u{2034}"), - ("trade", "\u{2122}"), - ("triangle", "\u{25B5}"), - ("triangledown", "\u{25BF}"), - ("triangleleft", "\u{25C3}"), - ("trianglelefteq", "\u{22B4}"), - ("triangleq", "\u{225C}"), - ("triangleright", "\u{25B9}"), - ("trianglerighteq", "\u{22B5}"), - ("tridot", "\u{25EC}"), - ("trie", "\u{225C}"), - ("triminus", "\u{2A3A}"), - ("triplus", "\u{2A39}"), - ("trisb", "\u{29CD}"), - ("tritime", "\u{2A3B}"), - ("trpezium", "\u{23E2}"), - ("tscr", "\u{1D4C9}"), - ("tscy", "\u{0446}"), - ("tshcy", "\u{045B}"), - ("tstrok", "\u{0167}"), - ("twixt", "\u{226C}"), - ("twoheadleftarrow", "\u{219E}"), - ("twoheadrightarrow", "\u{21A0}"), - ("uArr", "\u{21D1}"), - ("uHar", "\u{2963}"), - ("uacute", "\u{00FA}"), - ("uarr", "\u{2191}"), - ("ubrcy", "\u{045E}"), - ("ubreve", "\u{016D}"), - ("ucirc", "\u{00FB}"), - ("ucy", "\u{0443}"), - ("udarr", "\u{21C5}"), - ("udblac", "\u{0171}"), - ("udhar", "\u{296E}"), - ("ufisht", "\u{297E}"), - ("ufr", "\u{1D532}"), - ("ugrave", "\u{00F9}"), - ("uharl", "\u{21BF}"), - ("uharr", "\u{21BE}"), - ("uhblk", "\u{2580}"), - ("ulcorn", "\u{231C}"), - ("ulcorner", "\u{231C}"), - ("ulcrop", "\u{230F}"), - ("ultri", "\u{25F8}"), - ("umacr", "\u{016B}"), - ("uml", "\u{00A8}"), - ("uogon", "\u{0173}"), - ("uopf", "\u{1D566}"), - ("uparrow", "\u{2191}"), - ("updownarrow", "\u{2195}"), - ("upharpoonleft", "\u{21BF}"), - ("upharpoonright", "\u{21BE}"), - ("uplus", "\u{228E}"), - ("upsi", "\u{03C5}"), - ("upsih", "\u{03D2}"), - ("upsilon", "\u{03C5}"), - ("upuparrows", "\u{21C8}"), - ("urcorn", "\u{231D}"), - ("urcorner", "\u{231D}"), - ("urcrop", "\u{230E}"), - ("uring", "\u{016F}"), - ("urtri", "\u{25F9}"), - ("uscr", "\u{1D4CA}"), - ("utdot", "\u{22F0}"), - ("utilde", "\u{0169}"), - ("utri", "\u{25B5}"), - ("utrif", "\u{25B4}"), - ("uuarr", "\u{21C8}"), - ("uuml", "\u{00FC}"), - ("uwangle", "\u{29A7}"), - ("vArr", "\u{21D5}"), - ("vBar", "\u{2AE8}"), - ("vBarv", "\u{2AE9}"), - ("vDash", "\u{22A8}"), - ("vangrt", "\u{299C}"), - ("varepsilon", "\u{03F5}"), - ("varkappa", "\u{03F0}"), - ("varnothing", "\u{2205}"), - ("varphi", "\u{03D5}"), - ("varpi", "\u{03D6}"), - ("varpropto", "\u{221D}"), - ("varr", "\u{2195}"), - ("varrho", "\u{03F1}"), - ("varsigma", "\u{03C2}"), - ("varsubsetneq", "\u{228A}\u{FE00}"), - ("varsubsetneqq", "\u{2ACB}\u{FE00}"), - ("varsupsetneq", "\u{228B}\u{FE00}"), - ("varsupsetneqq", "\u{2ACC}\u{FE00}"), - ("vartheta", "\u{03D1}"), - ("vartriangleleft", "\u{22B2}"), - ("vartriangleright", "\u{22B3}"), - ("vcy", "\u{0432}"), - ("vdash", "\u{22A2}"), - ("vee", "\u{2228}"), - ("veebar", "\u{22BB}"), - ("veeeq", "\u{225A}"), - ("vellip", "\u{22EE}"), - ("verbar", "\u{007C}"), - ("vert", "\u{007C}"), - ("vfr", "\u{1D533}"), - ("vltri", "\u{22B2}"), - ("vnsub", "\u{2282}\u{20D2}"), - ("vnsup", "\u{2283}\u{20D2}"), - ("vopf", "\u{1D567}"), - ("vprop", "\u{221D}"), - ("vrtri", "\u{22B3}"), - ("vscr", "\u{1D4CB}"), - ("vsubnE", "\u{2ACB}\u{FE00}"), - ("vsubne", "\u{228A}\u{FE00}"), - ("vsupnE", "\u{2ACC}\u{FE00}"), - ("vsupne", "\u{228B}\u{FE00}"), - ("vzigzag", "\u{299A}"), - ("wcirc", "\u{0175}"), - ("wedbar", "\u{2A5F}"), - ("wedge", "\u{2227}"), - ("wedgeq", "\u{2259}"), - ("weierp", "\u{2118}"), - ("wfr", "\u{1D534}"), - ("wopf", "\u{1D568}"), - ("wp", "\u{2118}"), - ("wr", "\u{2240}"), - ("wreath", "\u{2240}"), - ("wscr", "\u{1D4CC}"), - ("xcap", "\u{22C2}"), - ("xcirc", "\u{25EF}"), - ("xcup", "\u{22C3}"), - ("xdtri", "\u{25BD}"), - ("xfr", "\u{1D535}"), - ("xhArr", "\u{27FA}"), - ("xharr", "\u{27F7}"), - ("xi", "\u{03BE}"), - ("xlArr", "\u{27F8}"), - ("xlarr", "\u{27F5}"), - ("xmap", "\u{27FC}"), - ("xnis", "\u{22FB}"), - ("xodot", "\u{2A00}"), - ("xopf", "\u{1D569}"), - ("xoplus", "\u{2A01}"), - ("xotime", "\u{2A02}"), - ("xrArr", "\u{27F9}"), - ("xrarr", "\u{27F6}"), - ("xscr", "\u{1D4CD}"), - ("xsqcup", "\u{2A06}"), - ("xuplus", "\u{2A04}"), - ("xutri", "\u{25B3}"), - ("xvee", "\u{22C1}"), - ("xwedge", "\u{22C0}"), - ("yacute", "\u{00FD}"), - ("yacy", "\u{044F}"), - ("ycirc", "\u{0177}"), - ("ycy", "\u{044B}"), - ("yen", "\u{00A5}"), - ("yfr", "\u{1D536}"), - ("yicy", "\u{0457}"), - ("yopf", "\u{1D56A}"), - ("yscr", "\u{1D4CE}"), - ("yucy", "\u{044E}"), - ("yuml", "\u{00FF}"), - ("zacute", "\u{017A}"), - ("zcaron", "\u{017E}"), - ("zcy", "\u{0437}"), - ("zdot", "\u{017C}"), - ("zeetrf", "\u{2128}"), - ("zeta", "\u{03B6}"), - ("zfr", "\u{1D537}"), - ("zhcy", "\u{0436}"), - ("zigrarr", "\u{21DD}"), - ("zopf", "\u{1D56B}"), - ("zscr", "\u{1D4CF}"), - ("zwj", "\u{200D}"), - ("zwnj", "\u{200C}"), -]; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_lookup_basic_xml_entities() { - assert_eq!(lookup_entity("amp"), Some("&")); - assert_eq!(lookup_entity("lt"), Some("<")); - assert_eq!(lookup_entity("gt"), Some(">")); - assert_eq!(lookup_entity("apos"), Some("'")); - assert_eq!(lookup_entity("quot"), Some("\"")); - } - - #[test] - fn test_lookup_html4_entities() { - assert_eq!(lookup_entity("nbsp"), Some("\u{00A0}")); - assert_eq!(lookup_entity("copy"), Some("\u{00A9}")); - assert_eq!(lookup_entity("reg"), Some("\u{00AE}")); - assert_eq!(lookup_entity("euro"), Some("\u{20AC}")); - assert_eq!(lookup_entity("mdash"), Some("\u{2014}")); - assert_eq!(lookup_entity("ndash"), Some("\u{2013}")); - assert_eq!(lookup_entity("hellip"), Some("\u{2026}")); - } - - #[test] - fn test_lookup_html5_new_entities() { - // Entities added in HTML5 that were not in HTML 4.01 - assert_eq!(lookup_entity("checkmark"), Some("\u{2713}")); - assert_eq!(lookup_entity("bigstar"), Some("\u{2605}")); - assert_eq!(lookup_entity("pitchfork"), Some("\u{22D4}")); - assert_eq!(lookup_entity("triangledown"), Some("\u{25BF}")); - assert_eq!(lookup_entity("lessgtr"), Some("\u{2276}")); - } - - #[test] - fn test_lookup_multi_codepoint_entities() { - // Entities that expand to multiple Unicode code points - assert_eq!(lookup_entity("NotEqualTilde"), Some("\u{2242}\u{0338}")); - assert_eq!(lookup_entity("nGt"), Some("\u{226B}\u{20D2}")); - assert_eq!(lookup_entity("nLt"), Some("\u{226A}\u{20D2}")); - } - - #[test] - fn test_lookup_greek_entities() { - assert_eq!(lookup_entity("Alpha"), Some("\u{0391}")); - assert_eq!(lookup_entity("alpha"), Some("\u{03B1}")); - assert_eq!(lookup_entity("Omega"), Some("\u{03A9}")); - assert_eq!(lookup_entity("omega"), Some("\u{03C9}")); - assert_eq!(lookup_entity("pi"), Some("\u{03C0}")); - } - - #[test] - fn test_reverse_lookup() { - // For codepoints with multiple entity names, the first alphabetically wins - assert_eq!(reverse_lookup_entity('\u{00A9}'), Some("COPY")); - assert_eq!(reverse_lookup_entity('\u{00A0}'), Some("NonBreakingSpace")); - // These codepoints have unique entity names - assert_eq!(reverse_lookup_entity('\u{0161}'), Some("scaron")); - assert_eq!(reverse_lookup_entity('\u{00E8}'), Some("egrave")); - assert_eq!(reverse_lookup_entity('\u{20AC}'), Some("euro")); - // ASCII characters should not have reverse lookups (handled separately) - assert_eq!(reverse_lookup_entity('A'), None); - assert_eq!(reverse_lookup_entity(' '), None); - } - - #[test] - fn test_lookup_nonexistent() { - assert_eq!(lookup_entity("nonexistent"), None); - assert_eq!(lookup_entity(""), None); - assert_eq!(lookup_entity("NBSP"), None); // case-sensitive - } - - #[test] - fn test_table_is_sorted() { - for window in ENTITIES.windows(2) { - assert!( - window[0].0 < window[1].0, - "entity table not sorted: {:?} should come before {:?}", - window[0].0, - window[1].0 - ); - } - } - - #[test] - fn test_entity_count() { - assert_eq!(ENTITIES.len(), 2125); - } -} diff --git a/browser/vendor/xmloxide/src/html5/mod.rs b/browser/vendor/xmloxide/src/html5/mod.rs deleted file mode 100644 index 6dc506b10..000000000 --- a/browser/vendor/xmloxide/src/html5/mod.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! WHATWG HTML5 parser. -//! -//! This module implements the [WHATWG HTML Living Standard] parsing algorithm, -//! including tokenization (§13.2.5), tree construction (§13.2.6), and the full -//! set of named character references (§13.5). -//! -//! The parser produces the same [`Document`](crate::tree::Document) tree -//! structure as the XML and HTML 4.01 parsers, using arena-allocated nodes. -//! -//! # Conformance -//! -//! - **Tokenizer:** 7032/7032 html5lib-tests passing (100%) -//! - **Tree construction:** 1778/1778 html5lib-tests passing (100%) -//! -//! # Quick start -//! -//! ``` -//! use xmloxide::html5::parse_html5; -//! -//! let doc = parse_html5("<p>Hello <b>world</b>").unwrap(); -//! let root = doc.root_element().unwrap(); -//! assert_eq!(doc.node_name(root), Some("html")); -//! ``` -//! -//! # Fragment parsing -//! -//! Fragment parsing (the algorithm behind `innerHTML`) is supported via -//! [`Html5ParseOptions::fragment_context`]: -//! -//! ``` -//! use xmloxide::html5::{parse_html5_with_options, Html5ParseOptions}; -//! -//! let opts = Html5ParseOptions { -//! scripting: false, -//! fragment_context: Some("body".to_string()), -//! }; -//! let doc = parse_html5_with_options("<p>fragment</p>", &opts).unwrap(); -//! ``` -//! -//! # Error reporting -//! -//! Use [`parse_html5_full`] to get the document tree together with all parse -//! errors (as [`ParseDiagnostic`](crate::error::ParseDiagnostic)s): -//! -//! ``` -//! use xmloxide::html5::parse_html5_full; -//! -//! let result = parse_html5_full("<p>text"); -//! println!("errors: {}", result.errors.len()); -//! let _doc = result.document; -//! ``` -//! -//! # Streaming (SAX-like) API -//! -//! For large documents where building a full DOM tree is unnecessary, the -//! [`sax`] submodule provides a callback-driven API that wraps the tokenizer -//! directly: -//! -//! ``` -//! use xmloxide::html5::sax::{Html5SaxHandler, parse_html5_sax}; -//! -//! struct Counter { elements: usize } -//! impl Html5SaxHandler for Counter { -//! fn start_element(&mut self, _name: &str, _attrs: &[(String, String)], _sc: bool) { -//! self.elements += 1; -//! } -//! } -//! -//! let mut h = Counter { elements: 0 }; -//! parse_html5_sax("<div><p>Hello</p></div>", &mut h); -//! assert_eq!(h.elements, 2); -//! ``` -//! -//! [WHATWG HTML Living Standard]: https://html.spec.whatwg.org/ - -pub mod entities; -pub mod sax; -pub mod tokenizer; -pub(crate) mod tree_builder; - -pub use tree_builder::{ - parse_html5, parse_html5_full, parse_html5_full_with_options, parse_html5_with_options, - Html5ParseOptions, Html5ParseResult, -}; diff --git a/browser/vendor/xmloxide/src/html5/sax.rs b/browser/vendor/xmloxide/src/html5/sax.rs deleted file mode 100644 index f7789b12b..000000000 --- a/browser/vendor/xmloxide/src/html5/sax.rs +++ /dev/null @@ -1,399 +0,0 @@ -//! Streaming SAX-like API for HTML5 parsing. -//! -//! Wraps the WHATWG HTML5 tokenizer to fire callbacks for each token -//! without building a DOM tree in memory. This is useful for large HTML -//! documents where you only need to extract specific data. -//! -//! # Examples -//! -//! ``` -//! use xmloxide::html5::sax::{Html5SaxHandler, parse_html5_sax}; -//! -//! struct Counter { elements: usize } -//! -//! impl Html5SaxHandler for Counter { -//! fn start_element( -//! &mut self, -//! name: &str, -//! attributes: &[(String, String)], -//! self_closing: bool, -//! ) { -//! self.elements += 1; -//! } -//! } -//! -//! let mut handler = Counter { elements: 0 }; -//! parse_html5_sax("<div><p>Hello</p></div>", &mut handler); -//! assert_eq!(handler.elements, 2); -//! ``` - -use super::tokenizer::{Token, Tokenizer, TokenizerError}; - -/// An event handler for streaming HTML5 parsing. -/// -/// Implement the callbacks you care about; all methods have default no-op -/// implementations so you only need to override what you need. -/// -/// # Attribute tuples -/// -/// Attributes are passed as `(name, value)` tuples matching the HTML5 -/// tokenizer's attribute representation. -#[allow(unused_variables)] -pub trait Html5SaxHandler { - /// Called when a start tag is encountered. - /// - /// `attributes` contains `(name, value)` tuples. - /// `self_closing` is true for self-closing tags like `<br/>`. - fn start_element(&mut self, name: &str, attributes: &[(String, String)], self_closing: bool) {} - - /// Called when an end tag is encountered. - fn end_element(&mut self, name: &str) {} - - /// Called for character data (text content). - /// - /// Note: the HTML5 tokenizer emits one character at a time; this API - /// coalesces consecutive characters into a single callback for efficiency. - fn characters(&mut self, content: &str) {} - - /// Called for HTML comments. - fn comment(&mut self, content: &str) {} - - /// Called for DOCTYPE declarations. - fn doctype(&mut self, name: Option<&str>, public_id: Option<&str>, system_id: Option<&str>) {} - - /// Called when a tokenizer error is encountered. - fn error(&mut self, error: &TokenizerError) {} -} - -/// A default no-op HTML5 SAX handler. Useful as a base or for testing. -pub struct DefaultHtml5Handler; - -impl Html5SaxHandler for DefaultHtml5Handler {} - -/// Parse HTML5 from a string, firing SAX events on the provided handler. -/// -/// This drives the WHATWG HTML5 tokenizer and calls the appropriate handler -/// methods for each token. No DOM tree is built. -/// -/// # Examples -/// -/// ``` -/// use xmloxide::html5::sax::{Html5SaxHandler, parse_html5_sax}; -/// -/// struct Links { hrefs: Vec<String> } -/// -/// impl Html5SaxHandler for Links { -/// fn start_element( -/// &mut self, -/// name: &str, -/// attributes: &[(String, String)], -/// self_closing: bool, -/// ) { -/// if name == "a" { -/// if let Some((_, href)) = attributes.iter().find(|(n, _)| n == "href") { -/// self.hrefs.push(href.clone()); -/// } -/// } -/// } -/// } -/// -/// let mut handler = Links { hrefs: Vec::new() }; -/// parse_html5_sax( -/// r#"<a href="https://example.com">Link</a>"#, -/// &mut handler, -/// ); -/// assert_eq!(handler.hrefs, vec!["https://example.com"]); -/// ``` -pub fn parse_html5_sax(input: &str, handler: &mut dyn Html5SaxHandler) { - let mut tokenizer = Tokenizer::new(input); - let mut char_buf = String::new(); - - loop { - let token = tokenizer.next_token(); - match token { - Token::Character(c) => { - char_buf.push(c); - continue; - } - _ => { - // Flush any accumulated characters before handling the - // non-character token. - if !char_buf.is_empty() { - handler.characters(&char_buf); - char_buf.clear(); - } - } - } - - match token { - Token::StartTag { - ref name, - ref attributes, - self_closing, - } => { - let attrs: Vec<(String, String)> = attributes - .iter() - .map(|a| (a.name.clone(), a.value.clone())) - .collect(); - handler.start_element(name, &attrs, self_closing); - } - Token::EndTag { ref name } => { - handler.end_element(name); - } - Token::Comment(ref text) => { - handler.comment(text); - } - Token::Doctype { - ref name, - ref public_id, - ref system_id, - .. - } => { - handler.doctype(name.as_deref(), public_id.as_deref(), system_id.as_deref()); - } - Token::Eof => break, - Token::Character(_) => unreachable!(), - } - } - - // Report any tokenizer errors - for error in tokenizer.errors() { - handler.error(error); - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - - #[test] - fn test_start_and_end_elements() { - struct Recorder { - events: Vec<String>, - } - impl Html5SaxHandler for Recorder { - fn start_element( - &mut self, - name: &str, - _attributes: &[(String, String)], - _self_closing: bool, - ) { - self.events.push(format!("start:{name}")); - } - fn end_element(&mut self, name: &str) { - self.events.push(format!("end:{name}")); - } - } - - let mut handler = Recorder { events: Vec::new() }; - parse_html5_sax("<div><p>text</p></div>", &mut handler); - assert_eq!( - handler.events, - vec!["start:div", "start:p", "end:p", "end:div"] - ); - } - - #[test] - fn test_characters_coalesced() { - struct TextCollector { - texts: Vec<String>, - } - impl Html5SaxHandler for TextCollector { - fn characters(&mut self, content: &str) { - self.texts.push(content.to_string()); - } - } - - let mut handler = TextCollector { texts: Vec::new() }; - parse_html5_sax("<p>Hello World</p>", &mut handler); - // Should be a single coalesced text event, not one per character - assert_eq!(handler.texts.len(), 1); - assert_eq!(handler.texts[0], "Hello World"); - } - - #[test] - fn test_attributes() { - struct AttrCollector { - attrs: Vec<Vec<(String, String)>>, - } - impl Html5SaxHandler for AttrCollector { - fn start_element( - &mut self, - _name: &str, - attributes: &[(String, String)], - _self_closing: bool, - ) { - self.attrs.push(attributes.to_vec()); - } - } - - let mut handler = AttrCollector { attrs: Vec::new() }; - parse_html5_sax( - r#"<a href="http://example.com" class="link">x</a>"#, - &mut handler, - ); - assert_eq!(handler.attrs.len(), 1); - assert_eq!(handler.attrs[0].len(), 2); - assert_eq!(handler.attrs[0][0].0, "href"); - assert_eq!(handler.attrs[0][0].1, "http://example.com"); - assert_eq!(handler.attrs[0][1].0, "class"); - assert_eq!(handler.attrs[0][1].1, "link"); - } - - #[test] - fn test_comment() { - struct CommentCollector { - comments: Vec<String>, - } - impl Html5SaxHandler for CommentCollector { - fn comment(&mut self, content: &str) { - self.comments.push(content.to_string()); - } - } - - let mut handler = CommentCollector { - comments: Vec::new(), - }; - parse_html5_sax("<!-- hello --><p>text</p>", &mut handler); - assert_eq!(handler.comments, vec![" hello "]); - } - - #[test] - fn test_doctype() { - struct DoctypeCollector { - name: Option<String>, - } - impl Html5SaxHandler for DoctypeCollector { - fn doctype( - &mut self, - name: Option<&str>, - _public_id: Option<&str>, - _system_id: Option<&str>, - ) { - self.name = name.map(String::from); - } - } - - let mut handler = DoctypeCollector { name: None }; - parse_html5_sax("<!DOCTYPE html><html></html>", &mut handler); - assert_eq!(handler.name, Some("html".to_string())); - } - - #[test] - fn test_self_closing() { - struct SelfClosingChecker { - self_closing_tags: Vec<String>, - } - impl Html5SaxHandler for SelfClosingChecker { - fn start_element( - &mut self, - name: &str, - _attributes: &[(String, String)], - self_closing: bool, - ) { - if self_closing { - self.self_closing_tags.push(name.to_string()); - } - } - } - - let mut handler = SelfClosingChecker { - self_closing_tags: Vec::new(), - }; - parse_html5_sax("<br/><img/><p>text</p>", &mut handler); - assert_eq!(handler.self_closing_tags, vec!["br", "img"]); - } - - #[test] - fn test_default_handler() { - let mut handler = DefaultHtml5Handler; - parse_html5_sax("<p>test</p>", &mut handler); - // Should not panic — all callbacks are no-ops - } - - #[test] - fn test_error_reporting() { - struct ErrorCounter { - count: usize, - } - impl Html5SaxHandler for ErrorCounter { - fn error(&mut self, _error: &TokenizerError) { - self.count += 1; - } - } - - let mut handler = ErrorCounter { count: 0 }; - // EOF inside a tag triggers eof-in-tag error - parse_html5_sax("<p><div attr=", &mut handler); - assert!(handler.count >= 1); - } - - #[test] - fn test_element_counter() { - struct Counter { - elements: usize, - } - impl Html5SaxHandler for Counter { - fn start_element( - &mut self, - _name: &str, - _attributes: &[(String, String)], - _self_closing: bool, - ) { - self.elements += 1; - } - } - - let mut handler = Counter { elements: 0 }; - parse_html5_sax( - "<html><head><title>Test</title></head><body><p>Hello</p></body></html>", - &mut handler, - ); - assert_eq!(handler.elements, 5); // html, head, title, body, p - } - - #[test] - fn test_multiple_text_segments() { - struct TextCollector { - texts: Vec<String>, - } - impl Html5SaxHandler for TextCollector { - fn characters(&mut self, content: &str) { - self.texts.push(content.to_string()); - } - } - - let mut handler = TextCollector { texts: Vec::new() }; - parse_html5_sax("<p>Hello</p><p>World</p>", &mut handler); - assert_eq!(handler.texts, vec!["Hello", "World"]); - } - - #[test] - fn test_link_extractor() { - struct LinkExtractor { - hrefs: Vec<String>, - } - impl Html5SaxHandler for LinkExtractor { - fn start_element( - &mut self, - name: &str, - attributes: &[(String, String)], - _self_closing: bool, - ) { - if name == "a" { - if let Some((_, href)) = attributes.iter().find(|(n, _)| n == "href") { - self.hrefs.push(href.clone()); - } - } - } - } - - let mut handler = LinkExtractor { hrefs: Vec::new() }; - parse_html5_sax( - r#"<a href="/one">1</a><a href="/two">2</a><span>no link</span>"#, - &mut handler, - ); - assert_eq!(handler.hrefs, vec!["/one", "/two"]); - } -} diff --git a/browser/vendor/xmloxide/src/html5/tokenizer.rs b/browser/vendor/xmloxide/src/html5/tokenizer.rs deleted file mode 100644 index 0002d5166..000000000 --- a/browser/vendor/xmloxide/src/html5/tokenizer.rs +++ /dev/null @@ -1,3374 +0,0 @@ -//! WHATWG HTML5 tokenizer state machine. -//! -//! This module implements the tokenization stage of the HTML parsing algorithm -//! as defined in the WHATWG HTML Living Standard. -//! -//! See <https://html.spec.whatwg.org/multipage/parsing.html#tokenization> - -use std::borrow::Cow; -use std::collections::VecDeque; - -use crate::html5::entities::lookup_entity; - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -/// A single token produced by the HTML5 tokenizer. -#[derive(Debug, Clone, PartialEq)] -pub enum Token { - /// A DOCTYPE token. - Doctype { - /// The DOCTYPE name (e.g. `html`). - name: Option<String>, - /// The public identifier, if any. - public_id: Option<String>, - /// The system identifier, if any. - system_id: Option<String>, - /// Whether the force-quirks flag is set. - force_quirks: bool, - }, - /// A start tag token. - StartTag { - /// The tag name. - name: String, - /// The list of attributes. - attributes: Vec<Attribute>, - /// Whether the self-closing flag is set. - self_closing: bool, - }, - /// An end tag token. - EndTag { - /// The tag name. - name: String, - }, - /// A single character token. - Character(char), - /// A comment token. - Comment(String), - /// End-of-file token. - Eof, -} - -/// An attribute on a start tag token. -#[derive(Debug, Clone, PartialEq)] -pub struct Attribute { - /// The attribute name. - pub name: String, - /// The attribute value. - pub value: String, -} - -/// A tokenizer error with a WHATWG-specified error code and byte position. -#[derive(Debug, Clone, PartialEq)] -pub struct TokenizerError { - /// The WHATWG error code string (e.g. `"eof-in-doctype"`). - pub code: &'static str, - /// Byte offset in the input where the error occurred. - pub span: usize, -} - -// --------------------------------------------------------------------------- -// Tokenizer states -// --------------------------------------------------------------------------- - -/// All states of the WHATWG HTML tokenizer state machine. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow(clippy::doc_markdown, dead_code)] -pub enum State { - Data, - RcData, - RawText, - ScriptData, - Plaintext, - TagOpen, - EndTagOpen, - TagName, - RcDataLessThanSign, - RcDataEndTagOpen, - RcDataEndTagName, - RawTextLessThanSign, - RawTextEndTagOpen, - RawTextEndTagName, - ScriptDataLessThanSign, - ScriptDataEndTagOpen, - ScriptDataEndTagName, - ScriptDataEscapeStart, - ScriptDataEscapeStartDash, - ScriptDataEscaped, - ScriptDataEscapedDash, - ScriptDataEscapedDashDash, - ScriptDataEscapedLessThanSign, - ScriptDataEscapedEndTagOpen, - ScriptDataEscapedEndTagName, - ScriptDataDoubleEscapeStart, - ScriptDataDoubleEscaped, - ScriptDataDoubleEscapedDash, - ScriptDataDoubleEscapedDashDash, - ScriptDataDoubleEscapedLessThanSign, - ScriptDataDoubleEscapeEnd, - BeforeAttributeName, - AttributeName, - AfterAttributeName, - BeforeAttributeValue, - AttributeValueDoubleQuoted, - AttributeValueSingleQuoted, - AttributeValueUnquoted, - AfterAttributeValueQuoted, - SelfClosingStartTag, - BogusComment, - MarkupDeclarationOpen, - CommentStart, - CommentStartDash, - Comment, - CommentLessThanSign, - CommentLessThanSignBang, - CommentLessThanSignBangDash, - CommentLessThanSignBangDashDash, - CommentEndDash, - CommentEnd, - CommentEndBang, - Doctype, - BeforeDoctypeName, - DoctypeName, - AfterDoctypeName, - AfterDoctypePublicKeyword, - BeforeDoctypePublicIdentifier, - DoctypePublicIdentifierDoubleQuoted, - DoctypePublicIdentifierSingleQuoted, - AfterDoctypePublicIdentifier, - BetweenDoctypePublicAndSystemIdentifiers, - AfterDoctypeSystemKeyword, - BeforeDoctypeSystemIdentifier, - DoctypeSystemIdentifierDoubleQuoted, - DoctypeSystemIdentifierSingleQuoted, - AfterDoctypeSystemIdentifier, - BogusDoctype, - CdataSection, - CdataSectionBracket, - CdataSectionEnd, - CharacterReference, - NamedCharacterReference, - AmbiguousAmpersand, - NumericCharacterReference, - HexadecimalCharacterReferenceStart, - DecimalCharacterReferenceStart, - HexadecimalCharacterReference, - DecimalCharacterReference, - NumericCharacterReferenceEnd, -} - -// --------------------------------------------------------------------------- -// Tokenizer -// --------------------------------------------------------------------------- - -/// The WHATWG HTML5 tokenizer. -/// -/// Converts an input string into a sequence of [`Token`] values by walking -/// through the state machine described in the WHATWG specification. -#[allow(clippy::struct_excessive_bools)] -pub struct Tokenizer<'a> { - input: Cow<'a, str>, - pos: usize, - state: State, - return_state: State, - // Current tag being built - current_tag_name: String, - current_tag_attrs: Vec<Attribute>, - current_tag_self_closing: bool, - current_tag_is_end: bool, - current_attr_name: String, - current_attr_value: String, - // Current comment being built - current_comment: String, - // Current DOCTYPE being built - current_doctype_name: Option<String>, - current_doctype_public_id: Option<String>, - current_doctype_system_id: Option<String>, - current_doctype_force_quirks: bool, - // Temporary buffer (character references, end-tag matching) - temp_buffer: String, - // Output queue – tokens waiting to be returned (FIFO) - pending_tokens: VecDeque<Token>, - // Error tracking - errors: Vec<TokenizerError>, - // Last emitted start tag name (for appropriate end tag checks) - last_start_tag_name: Option<String>, - // Character reference accumulator - char_ref_code: u32, - // Whether the adjusted current node is in a foreign (non-HTML) namespace. - // Set by the tree builder; controls CDATA section handling. - allow_cdata: bool, -} - -impl<'a> Tokenizer<'a> { - /// Creates a new tokenizer for the given input string. - /// - /// The input is preprocessed to normalize newlines per the WHATWG spec: - /// CR (U+000D) and CR+LF pairs are replaced with LF (U+000A). - pub fn new(input: &'a str) -> Self { - let input = if input.contains('\r') { - Cow::Owned(normalize_newlines(input)) - } else { - Cow::Borrowed(input) - }; - Self { - input, - pos: 0, - state: State::Data, - return_state: State::Data, - current_tag_name: String::new(), - current_tag_attrs: Vec::new(), - current_tag_self_closing: false, - current_tag_is_end: false, - current_attr_name: String::new(), - current_attr_value: String::new(), - current_comment: String::new(), - current_doctype_name: None, - current_doctype_public_id: None, - current_doctype_system_id: None, - current_doctype_force_quirks: false, - temp_buffer: String::new(), - pending_tokens: VecDeque::new(), - errors: Vec::new(), - last_start_tag_name: None, - char_ref_code: 0, - allow_cdata: false, - } - } - - /// Returns a reference to the errors collected so far. - pub fn errors(&self) -> &[TokenizerError] { - &self.errors - } - - /// Allows the tree builder to switch the tokenizer state (e.g. to - /// `RcData` or `RawText` when entering `<textarea>` or `<style>`). - pub fn set_state(&mut self, state: State) { - self.state = state; - } - - /// Sets the tokenizer state from a string name (for test harnesses). - /// - /// Accepted names: `"Data"`, `"Plaintext"`, `"RcData"`, `"RawText"`, - /// `"ScriptData"`, `"CDataSection"`. Unknown names are ignored. - pub fn set_state_for_test(&mut self, name: &str) { - let state = match name { - "Data" => State::Data, - "Plaintext" => State::Plaintext, - "RcData" => State::RcData, - "RawText" => State::RawText, - "ScriptData" => State::ScriptData, - "CDataSection" => State::CdataSection, - _ => return, - }; - self.state = state; - } - - /// Sets whether the adjusted current node is in a foreign (non-HTML) - /// namespace. When `true`, the tokenizer will handle `<![CDATA[` as a - /// CDATA section; when `false`, it will be treated as a bogus comment. - pub fn set_allow_cdata(&mut self, allow: bool) { - self.allow_cdata = allow; - } - - /// Allows the tree builder to inform the tokenizer of the last emitted - /// start tag name, so the tokenizer can match appropriate end tags in - /// RCDATA/RAWTEXT/Script data states. - pub fn set_last_start_tag(&mut self, name: &str) { - self.last_start_tag_name = Some(name.to_string()); - } - - /// Returns the next token from the input. - /// - /// Returns [`Token::Eof`] when the input is exhausted. - #[allow(clippy::too_many_lines)] - pub fn next_token(&mut self) -> Token { - // Drain pending queue first (character reference expansions, etc.) - if let Some(tok) = self.pending_tokens.pop_front() { - return tok; - } - loop { - if let Some(tok) = self.pending_tokens.pop_front() { - return tok; - } - match self.state { - State::Data => self.state_data(), - State::RcData => self.state_rcdata(), - State::RawText => self.state_rawtext(), - State::ScriptData => self.state_script_data(), - State::Plaintext => self.state_plaintext(), - State::TagOpen => self.state_tag_open(), - State::EndTagOpen => self.state_end_tag_open(), - State::TagName => self.state_tag_name(), - State::RcDataLessThanSign => self.state_rcdata_less_than_sign(), - State::RcDataEndTagOpen => self.state_rcdata_end_tag_open(), - State::RcDataEndTagName => self.state_rcdata_end_tag_name(), - State::RawTextLessThanSign => self.state_rawtext_less_than_sign(), - State::RawTextEndTagOpen => self.state_rawtext_end_tag_open(), - State::RawTextEndTagName => self.state_rawtext_end_tag_name(), - State::ScriptDataLessThanSign => self.state_script_data_less_than_sign(), - State::ScriptDataEndTagOpen => self.state_script_data_end_tag_open(), - State::ScriptDataEndTagName => self.state_script_data_end_tag_name(), - State::ScriptDataEscapeStart => self.state_script_data_escape_start(), - State::ScriptDataEscapeStartDash => { - self.state_script_data_escape_start_dash(); - } - State::ScriptDataEscaped => self.state_script_data_escaped(), - State::ScriptDataEscapedDash => self.state_script_data_escaped_dash(), - State::ScriptDataEscapedDashDash => { - self.state_script_data_escaped_dash_dash(); - } - State::ScriptDataEscapedLessThanSign => { - self.state_script_data_escaped_less_than_sign(); - } - State::ScriptDataEscapedEndTagOpen => { - self.state_script_data_escaped_end_tag_open(); - } - State::ScriptDataEscapedEndTagName => { - self.state_script_data_escaped_end_tag_name(); - } - State::ScriptDataDoubleEscapeStart => { - self.state_script_data_double_escape_start(); - } - State::ScriptDataDoubleEscaped => { - self.state_script_data_double_escaped(); - } - State::ScriptDataDoubleEscapedDash => { - self.state_script_data_double_escaped_dash(); - } - State::ScriptDataDoubleEscapedDashDash => { - self.state_script_data_double_escaped_dash_dash(); - } - State::ScriptDataDoubleEscapedLessThanSign => { - self.state_script_data_double_escaped_less_than_sign(); - } - State::ScriptDataDoubleEscapeEnd => { - self.state_script_data_double_escape_end(); - } - State::BeforeAttributeName => self.state_before_attribute_name(), - State::AttributeName => self.state_attribute_name(), - State::AfterAttributeName => self.state_after_attribute_name(), - State::BeforeAttributeValue => self.state_before_attribute_value(), - State::AttributeValueDoubleQuoted => { - self.state_attribute_value_double_quoted(); - } - State::AttributeValueSingleQuoted => { - self.state_attribute_value_single_quoted(); - } - State::AttributeValueUnquoted => self.state_attribute_value_unquoted(), - State::AfterAttributeValueQuoted => { - self.state_after_attribute_value_quoted(); - } - State::SelfClosingStartTag => self.state_self_closing_start_tag(), - State::BogusComment => self.state_bogus_comment(), - State::MarkupDeclarationOpen => self.state_markup_declaration_open(), - State::CommentStart => self.state_comment_start(), - State::CommentStartDash => self.state_comment_start_dash(), - State::Comment => self.state_comment(), - State::CommentLessThanSign => self.state_comment_less_than_sign(), - State::CommentLessThanSignBang => { - self.state_comment_less_than_sign_bang(); - } - State::CommentLessThanSignBangDash => { - self.state_comment_less_than_sign_bang_dash(); - } - State::CommentLessThanSignBangDashDash => { - self.state_comment_less_than_sign_bang_dash_dash(); - } - State::CommentEndDash => self.state_comment_end_dash(), - State::CommentEnd => self.state_comment_end(), - State::CommentEndBang => self.state_comment_end_bang(), - State::Doctype => self.state_doctype(), - State::BeforeDoctypeName => self.state_before_doctype_name(), - State::DoctypeName => self.state_doctype_name(), - State::AfterDoctypeName => self.state_after_doctype_name(), - State::AfterDoctypePublicKeyword => { - self.state_after_doctype_public_keyword(); - } - State::BeforeDoctypePublicIdentifier => { - self.state_before_doctype_public_identifier(); - } - State::DoctypePublicIdentifierDoubleQuoted => { - self.state_doctype_public_identifier_double_quoted(); - } - State::DoctypePublicIdentifierSingleQuoted => { - self.state_doctype_public_identifier_single_quoted(); - } - State::AfterDoctypePublicIdentifier => { - self.state_after_doctype_public_identifier(); - } - State::BetweenDoctypePublicAndSystemIdentifiers => { - self.state_between_doctype_public_and_system_identifiers(); - } - State::AfterDoctypeSystemKeyword => { - self.state_after_doctype_system_keyword(); - } - State::BeforeDoctypeSystemIdentifier => { - self.state_before_doctype_system_identifier(); - } - State::DoctypeSystemIdentifierDoubleQuoted => { - self.state_doctype_system_identifier_double_quoted(); - } - State::DoctypeSystemIdentifierSingleQuoted => { - self.state_doctype_system_identifier_single_quoted(); - } - State::AfterDoctypeSystemIdentifier => { - self.state_after_doctype_system_identifier(); - } - State::BogusDoctype => self.state_bogus_doctype(), - State::CdataSection => self.state_cdata_section(), - State::CdataSectionBracket => self.state_cdata_section_bracket(), - State::CdataSectionEnd => self.state_cdata_section_end(), - State::CharacterReference => self.state_character_reference(), - State::NamedCharacterReference => { - self.state_named_character_reference(); - } - State::AmbiguousAmpersand => self.state_ambiguous_ampersand(), - State::NumericCharacterReference => { - self.state_numeric_character_reference(); - } - State::HexadecimalCharacterReferenceStart => { - self.state_hexadecimal_character_reference_start(); - } - State::DecimalCharacterReferenceStart => { - self.state_decimal_character_reference_start(); - } - State::HexadecimalCharacterReference => { - self.state_hexadecimal_character_reference(); - } - State::DecimalCharacterReference => { - self.state_decimal_character_reference(); - } - State::NumericCharacterReferenceEnd => { - self.state_numeric_character_reference_end(); - } - } - } - } - - // ----------------------------------------------------------------------- - // Helpers - // ----------------------------------------------------------------------- - - /// Peek at the next character without consuming it. - fn peek(&self) -> Option<char> { - self.input[self.pos..].chars().next() - } - - /// Consume and return the next character, advancing `pos`. - fn consume(&mut self) -> Option<char> { - let ch = self.input[self.pos..].chars().next()?; - self.pos += ch.len_utf8(); - Some(ch) - } - - /// Reconsume: back up by the byte-length of the given character. - fn reconsume(&mut self, ch: char) { - self.pos -= ch.len_utf8(); - } - - /// Check if the upcoming input (case-insensitively) matches `needle`, - /// without consuming. `needle` must be ASCII. - fn next_chars_are_ascii_ci(&self, needle: &str) -> bool { - let remaining = self.input.as_bytes(); - if self.pos + needle.len() > remaining.len() { - return false; - } - remaining[self.pos..self.pos + needle.len()].eq_ignore_ascii_case(needle.as_bytes()) - } - - /// Push a parse error. - fn emit_error(&mut self, code: &'static str) { - self.errors.push(TokenizerError { - code, - span: self.pos, - }); - } - - /// Emit a character token (pushes to pending queue). - fn emit_char(&mut self, ch: char) { - self.pending_tokens.push_back(Token::Character(ch)); - } - - /// Emit EOF. - fn emit_eof(&mut self) { - self.pending_tokens.push_back(Token::Eof); - } - - /// Emit the current comment token. - fn emit_comment(&mut self) { - let comment = std::mem::take(&mut self.current_comment); - self.pending_tokens.push_back(Token::Comment(comment)); - } - - /// Emit the current tag token (start or end). - fn emit_current_tag(&mut self) { - self.finish_current_attr(); - if self.current_tag_is_end { - self.pending_tokens.push_back(Token::EndTag { - name: std::mem::take(&mut self.current_tag_name), - }); - } else { - let name = std::mem::take(&mut self.current_tag_name); - self.last_start_tag_name = Some(name.clone()); - self.pending_tokens.push_back(Token::StartTag { - name, - attributes: std::mem::take(&mut self.current_tag_attrs), - self_closing: self.current_tag_self_closing, - }); - } - self.current_tag_self_closing = false; - } - - /// Emit the current DOCTYPE token. - fn emit_doctype(&mut self) { - self.pending_tokens.push_back(Token::Doctype { - name: self.current_doctype_name.take(), - public_id: self.current_doctype_public_id.take(), - system_id: self.current_doctype_system_id.take(), - force_quirks: self.current_doctype_force_quirks, - }); - self.current_doctype_force_quirks = false; - } - - /// Start building a new start-tag token. - fn create_start_tag(&mut self) { - self.current_tag_name.clear(); - self.current_tag_attrs.clear(); - self.current_tag_self_closing = false; - self.current_tag_is_end = false; - self.current_attr_name.clear(); - self.current_attr_value.clear(); - } - - /// Start building a new end-tag token. - fn create_end_tag(&mut self) { - self.current_tag_name.clear(); - self.current_tag_attrs.clear(); - self.current_tag_self_closing = false; - self.current_tag_is_end = true; - self.current_attr_name.clear(); - self.current_attr_value.clear(); - } - - /// Start a new attribute on the current tag. - fn start_new_attr(&mut self) { - self.finish_current_attr(); - self.current_attr_name.clear(); - self.current_attr_value.clear(); - } - - /// Finish the current attribute (push it to the tag's attribute list - /// if the name is non-empty and not a duplicate). - fn finish_current_attr(&mut self) { - if self.current_attr_name.is_empty() { - return; - } - let name = std::mem::take(&mut self.current_attr_name); - let value = std::mem::take(&mut self.current_attr_value); - // The spec says duplicate attributes are parse errors; keep first. - if !self.current_tag_attrs.iter().any(|a| a.name == name) { - self.current_tag_attrs.push(Attribute { name, value }); - } - } - - /// Create a new DOCTYPE token with all fields empty. - fn create_doctype(&mut self) { - self.current_doctype_name = None; - self.current_doctype_public_id = None; - self.current_doctype_system_id = None; - self.current_doctype_force_quirks = false; - } - - /// Check if the current end tag is an appropriate end tag - /// (its name matches the last emitted start tag name). - fn is_appropriate_end_tag(&self) -> bool { - if let Some(ref last) = self.last_start_tag_name { - *last == self.current_tag_name - } else { - false - } - } - - /// Flush code points consumed as a character reference. - /// - /// If the return state is an attribute value state, append `temp_buffer` - /// to the current attribute value; otherwise emit each character. - fn flush_code_points_consumed_as_char_ref(&mut self) { - let buf = std::mem::take(&mut self.temp_buffer); - if is_attr_value_state(self.return_state) { - self.current_attr_value.push_str(&buf); - } else { - // Emit each char individually. - for ch in buf.chars() { - self.pending_tokens.push_back(Token::Character(ch)); - } - } - } - - // ----------------------------------------------------------------------- - // State implementations - // ----------------------------------------------------------------------- - - // 13.2.5.1 Data state - fn state_data(&mut self) { - // Fast path: scan forward through bytes that don't need special - // handling (not '<', '&', or '\0'). This avoids per-character - // overhead for plain text runs. - let bytes = self.input.as_bytes(); - let start = self.pos; - let mut i = start; - while i < bytes.len() { - let b = bytes[i]; - if b == b'<' || b == b'&' || b == 0 { - break; - } - i += 1; - } - if i > start { - // All bytes in start..i are safe plain text (no null, no < or &). - // Because we only break on ASCII bytes and skip non-ASCII bytes, - // start..i is always a valid UTF-8 slice boundary. - for c in self.input[start..i].chars() { - self.pending_tokens.push_back(Token::Character(c)); - } - self.pos = i; - return; - } - - // Slow path: handle special characters one at a time. - match self.consume() { - Some('&') => { - self.return_state = State::Data; - self.state = State::CharacterReference; - } - Some('<') => { - self.state = State::TagOpen; - } - Some('\0') => { - // Per spec: emit the null character as-is (with a parse error). - // Unlike RCDATA/RAWTEXT, Data state does NOT replace with U+FFFD. - self.emit_error("unexpected-null-character"); - self.emit_char('\0'); - } - None => { - self.emit_eof(); - } - Some(c) => { - self.emit_char(c); - } - } - } - - // 13.2.5.2 RCDATA state - fn state_rcdata(&mut self) { - match self.consume() { - Some('&') => { - self.return_state = State::RcData; - self.state = State::CharacterReference; - } - Some('<') => { - self.state = State::RcDataLessThanSign; - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - self.emit_char('\u{FFFD}'); - } - None => { - self.emit_eof(); - } - Some(c) => { - self.emit_char(c); - } - } - } - - // 13.2.5.3 RAWTEXT state - fn state_rawtext(&mut self) { - match self.consume() { - Some('<') => { - self.state = State::RawTextLessThanSign; - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - self.emit_char('\u{FFFD}'); - } - None => { - self.emit_eof(); - } - Some(c) => { - self.emit_char(c); - } - } - } - - // 13.2.5.4 Script data state - fn state_script_data(&mut self) { - match self.consume() { - Some('<') => { - self.state = State::ScriptDataLessThanSign; - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - self.emit_char('\u{FFFD}'); - } - None => { - self.emit_eof(); - } - Some(c) => { - self.emit_char(c); - } - } - } - - // 13.2.5.5 PLAINTEXT state - fn state_plaintext(&mut self) { - match self.consume() { - Some('\0') => { - self.emit_error("unexpected-null-character"); - self.emit_char('\u{FFFD}'); - } - None => { - self.emit_eof(); - } - Some(c) => { - self.emit_char(c); - } - } - } - - // 13.2.5.6 Tag open state - fn state_tag_open(&mut self) { - match self.consume() { - Some('!') => { - self.state = State::MarkupDeclarationOpen; - } - Some('/') => { - self.state = State::EndTagOpen; - } - Some(c) if c.is_ascii_alphabetic() => { - self.create_start_tag(); - self.reconsume(c); - self.state = State::TagName; - } - Some('?') => { - self.emit_error("unexpected-question-mark-instead-of-tag-name"); - self.current_comment.clear(); - self.reconsume('?'); - self.state = State::BogusComment; - } - None => { - self.emit_error("eof-before-tag-name"); - self.emit_char('<'); - self.emit_eof(); - } - Some(c) => { - self.emit_error("invalid-first-character-of-tag-name"); - self.reconsume(c); - self.state = State::Data; - self.emit_char('<'); - } - } - } - - // 13.2.5.7 End tag open state - fn state_end_tag_open(&mut self) { - match self.consume() { - Some(c) if c.is_ascii_alphabetic() => { - self.create_end_tag(); - self.reconsume(c); - self.state = State::TagName; - } - Some('>') => { - self.emit_error("missing-end-tag-name"); - self.state = State::Data; - } - None => { - self.emit_error("eof-before-tag-name"); - self.emit_char('<'); - self.emit_char('/'); - self.emit_eof(); - } - Some(c) => { - self.emit_error("invalid-first-character-of-tag-name"); - self.current_comment.clear(); - self.reconsume(c); - self.state = State::BogusComment; - } - } - } - - // 13.2.5.8 Tag name state - fn state_tag_name(&mut self) { - // Fast path: scan ahead through ASCII lowercase tag-name characters. - let bytes = self.input.as_bytes(); - let start = self.pos; - let mut i = start; - while i < bytes.len() { - let b = bytes[i]; - match b { - b'\t' | b'\n' | 0x0C | b' ' | b'/' | b'>' | 0 | 0x80..=0xFF => break, - b'A'..=b'Z' => { - self.current_tag_name.push((b + 32) as char); - i += 1; - } - _ => { - self.current_tag_name.push(b as char); - i += 1; - } - } - } - self.pos = i; - - // Now handle the terminating character. - match self.consume() { - Some('\t' | '\n' | '\x0C' | ' ') => { - self.state = State::BeforeAttributeName; - } - Some('/') => { - self.state = State::SelfClosingStartTag; - } - Some('>') => { - self.state = State::Data; - self.emit_current_tag(); - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - self.current_tag_name.push('\u{FFFD}'); - } - None => { - self.emit_error("eof-in-tag"); - self.emit_eof(); - } - Some(c) => { - self.current_tag_name.push(c.to_ascii_lowercase()); - } - } - } - - // 13.2.5.9 RCDATA less-than sign state - fn state_rcdata_less_than_sign(&mut self) { - if let Some('/') = self.peek() { - self.consume(); - self.temp_buffer.clear(); - self.state = State::RcDataEndTagOpen; - } else { - self.state = State::RcData; - self.emit_char('<'); - } - } - - // 13.2.5.10 RCDATA end tag open state - fn state_rcdata_end_tag_open(&mut self) { - match self.peek() { - Some(c) if c.is_ascii_alphabetic() => { - self.create_end_tag(); - self.state = State::RcDataEndTagName; - } - _ => { - self.state = State::RcData; - self.emit_char('<'); - self.emit_char('/'); - } - } - } - - // 13.2.5.11 RCDATA end tag name state - fn state_rcdata_end_tag_name(&mut self) { - match self.consume() { - Some(c @ ('\t' | '\n' | '\x0C' | ' ')) => { - if self.is_appropriate_end_tag() { - self.state = State::BeforeAttributeName; - } else { - self.emit_char('<'); - self.emit_char('/'); - self.emit_temp_buffer_chars(); - self.reconsume(c); - self.state = State::RcData; - } - } - Some('/') => { - if self.is_appropriate_end_tag() { - self.state = State::SelfClosingStartTag; - } else { - self.emit_char('<'); - self.emit_char('/'); - self.emit_temp_buffer_chars(); - self.reconsume('/'); - self.state = State::RcData; - } - } - Some('>') => { - if self.is_appropriate_end_tag() { - self.state = State::Data; - self.emit_current_tag(); - } else { - self.emit_char('<'); - self.emit_char('/'); - self.emit_temp_buffer_chars(); - self.reconsume('>'); - self.state = State::RcData; - } - } - Some(c) if c.is_ascii_alphabetic() => { - self.current_tag_name.push(c.to_ascii_lowercase()); - self.temp_buffer.push(c); - } - None => { - self.emit_char('<'); - self.emit_char('/'); - self.emit_temp_buffer_chars(); - self.state = State::RcData; - } - Some(c) => { - self.emit_char('<'); - self.emit_char('/'); - self.emit_temp_buffer_chars(); - self.reconsume(c); - self.state = State::RcData; - } - } - } - - /// Emit each character in `temp_buffer` as a character token. - fn emit_temp_buffer_chars(&mut self) { - let buf = std::mem::take(&mut self.temp_buffer); - for ch in buf.chars() { - self.emit_char(ch); - } - } - - // 13.2.5.12 RAWTEXT less-than sign state - fn state_rawtext_less_than_sign(&mut self) { - if let Some('/') = self.peek() { - self.consume(); - self.temp_buffer.clear(); - self.state = State::RawTextEndTagOpen; - } else { - self.state = State::RawText; - self.emit_char('<'); - } - } - - // 13.2.5.13 RAWTEXT end tag open state - fn state_rawtext_end_tag_open(&mut self) { - match self.peek() { - Some(c) if c.is_ascii_alphabetic() => { - self.create_end_tag(); - self.state = State::RawTextEndTagName; - } - _ => { - self.state = State::RawText; - self.emit_char('<'); - self.emit_char('/'); - } - } - } - - // 13.2.5.14 RAWTEXT end tag name state - fn state_rawtext_end_tag_name(&mut self) { - match self.consume() { - Some(c @ ('\t' | '\n' | '\x0C' | ' ')) => { - if self.is_appropriate_end_tag() { - self.state = State::BeforeAttributeName; - } else { - self.emit_char('<'); - self.emit_char('/'); - self.emit_temp_buffer_chars(); - self.reconsume(c); - self.state = State::RawText; - } - } - Some('/') => { - if self.is_appropriate_end_tag() { - self.state = State::SelfClosingStartTag; - } else { - self.emit_char('<'); - self.emit_char('/'); - self.emit_temp_buffer_chars(); - self.reconsume('/'); - self.state = State::RawText; - } - } - Some('>') => { - if self.is_appropriate_end_tag() { - self.state = State::Data; - self.emit_current_tag(); - } else { - self.emit_char('<'); - self.emit_char('/'); - self.emit_temp_buffer_chars(); - self.reconsume('>'); - self.state = State::RawText; - } - } - Some(c) if c.is_ascii_alphabetic() => { - self.current_tag_name.push(c.to_ascii_lowercase()); - self.temp_buffer.push(c); - } - None => { - self.emit_char('<'); - self.emit_char('/'); - self.emit_temp_buffer_chars(); - self.state = State::RawText; - } - Some(c) => { - self.emit_char('<'); - self.emit_char('/'); - self.emit_temp_buffer_chars(); - self.reconsume(c); - self.state = State::RawText; - } - } - } - - // 13.2.5.15 Script data less-than sign state - fn state_script_data_less_than_sign(&mut self) { - match self.peek() { - Some('/') => { - self.consume(); - self.temp_buffer.clear(); - self.state = State::ScriptDataEndTagOpen; - } - Some('!') => { - self.consume(); - self.state = State::ScriptDataEscapeStart; - self.emit_char('<'); - self.emit_char('!'); - } - _ => { - self.state = State::ScriptData; - self.emit_char('<'); - } - } - } - - // 13.2.5.16 Script data end tag open state - fn state_script_data_end_tag_open(&mut self) { - match self.peek() { - Some(c) if c.is_ascii_alphabetic() => { - self.create_end_tag(); - self.state = State::ScriptDataEndTagName; - } - _ => { - self.state = State::ScriptData; - self.emit_char('<'); - self.emit_char('/'); - } - } - } - - // 13.2.5.17 Script data end tag name state - fn state_script_data_end_tag_name(&mut self) { - match self.consume() { - Some(c @ ('\t' | '\n' | '\x0C' | ' ')) => { - if self.is_appropriate_end_tag() { - self.state = State::BeforeAttributeName; - } else { - self.emit_char('<'); - self.emit_char('/'); - self.emit_temp_buffer_chars(); - self.reconsume(c); - self.state = State::ScriptData; - } - } - Some('/') => { - if self.is_appropriate_end_tag() { - self.state = State::SelfClosingStartTag; - } else { - self.emit_char('<'); - self.emit_char('/'); - self.emit_temp_buffer_chars(); - self.reconsume('/'); - self.state = State::ScriptData; - } - } - Some('>') => { - if self.is_appropriate_end_tag() { - self.state = State::Data; - self.emit_current_tag(); - } else { - self.emit_char('<'); - self.emit_char('/'); - self.emit_temp_buffer_chars(); - self.reconsume('>'); - self.state = State::ScriptData; - } - } - Some(c) if c.is_ascii_alphabetic() => { - self.current_tag_name.push(c.to_ascii_lowercase()); - self.temp_buffer.push(c); - } - None => { - self.emit_char('<'); - self.emit_char('/'); - self.emit_temp_buffer_chars(); - self.state = State::ScriptData; - } - Some(c) => { - self.emit_char('<'); - self.emit_char('/'); - self.emit_temp_buffer_chars(); - self.reconsume(c); - self.state = State::ScriptData; - } - } - } - - // 13.2.5.18 Script data escape start state - fn state_script_data_escape_start(&mut self) { - match self.peek() { - Some('-') => { - self.consume(); - self.state = State::ScriptDataEscapeStartDash; - self.emit_char('-'); - } - _ => { - self.state = State::ScriptData; - } - } - } - - // 13.2.5.19 Script data escape start dash state - fn state_script_data_escape_start_dash(&mut self) { - match self.peek() { - Some('-') => { - self.consume(); - self.state = State::ScriptDataEscapedDashDash; - self.emit_char('-'); - } - _ => { - self.state = State::ScriptData; - } - } - } - - // 13.2.5.20 Script data escaped state - fn state_script_data_escaped(&mut self) { - match self.consume() { - Some('-') => { - self.state = State::ScriptDataEscapedDash; - self.emit_char('-'); - } - Some('<') => { - self.state = State::ScriptDataEscapedLessThanSign; - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - self.emit_char('\u{FFFD}'); - } - None => { - self.emit_error("eof-in-script-html-comment-like-text"); - self.emit_eof(); - } - Some(c) => { - self.emit_char(c); - } - } - } - - // 13.2.5.21 Script data escaped dash state - fn state_script_data_escaped_dash(&mut self) { - match self.consume() { - Some('-') => { - self.state = State::ScriptDataEscapedDashDash; - self.emit_char('-'); - } - Some('<') => { - self.state = State::ScriptDataEscapedLessThanSign; - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - self.state = State::ScriptDataEscaped; - self.emit_char('\u{FFFD}'); - } - None => { - self.emit_error("eof-in-script-html-comment-like-text"); - self.emit_eof(); - } - Some(c) => { - self.state = State::ScriptDataEscaped; - self.emit_char(c); - } - } - } - - // 13.2.5.22 Script data escaped dash dash state - fn state_script_data_escaped_dash_dash(&mut self) { - match self.consume() { - Some('-') => { - self.emit_char('-'); - } - Some('<') => { - self.state = State::ScriptDataEscapedLessThanSign; - } - Some('>') => { - self.state = State::ScriptData; - self.emit_char('>'); - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - self.state = State::ScriptDataEscaped; - self.emit_char('\u{FFFD}'); - } - None => { - self.emit_error("eof-in-script-html-comment-like-text"); - self.emit_eof(); - } - Some(c) => { - self.state = State::ScriptDataEscaped; - self.emit_char(c); - } - } - } - - // 13.2.5.23 Script data escaped less-than sign state - fn state_script_data_escaped_less_than_sign(&mut self) { - match self.peek() { - Some('/') => { - self.consume(); - self.temp_buffer.clear(); - self.state = State::ScriptDataEscapedEndTagOpen; - } - Some(c) if c.is_ascii_alphabetic() => { - self.temp_buffer.clear(); - self.emit_char('<'); - self.state = State::ScriptDataDoubleEscapeStart; - } - _ => { - self.emit_char('<'); - self.state = State::ScriptDataEscaped; - } - } - } - - // 13.2.5.24 Script data escaped end tag open state - fn state_script_data_escaped_end_tag_open(&mut self) { - match self.peek() { - Some(c) if c.is_ascii_alphabetic() => { - self.create_end_tag(); - self.state = State::ScriptDataEscapedEndTagName; - } - _ => { - self.emit_char('<'); - self.emit_char('/'); - self.state = State::ScriptDataEscaped; - } - } - } - - // 13.2.5.25 Script data escaped end tag name state - fn state_script_data_escaped_end_tag_name(&mut self) { - match self.consume() { - Some(c @ ('\t' | '\n' | '\x0C' | ' ')) => { - if self.is_appropriate_end_tag() { - self.state = State::BeforeAttributeName; - } else { - self.emit_char('<'); - self.emit_char('/'); - self.emit_temp_buffer_chars(); - self.reconsume(c); - self.state = State::ScriptDataEscaped; - } - } - Some('/') => { - if self.is_appropriate_end_tag() { - self.state = State::SelfClosingStartTag; - } else { - self.emit_char('<'); - self.emit_char('/'); - self.emit_temp_buffer_chars(); - self.reconsume('/'); - self.state = State::ScriptDataEscaped; - } - } - Some('>') => { - if self.is_appropriate_end_tag() { - self.state = State::Data; - self.emit_current_tag(); - } else { - self.emit_char('<'); - self.emit_char('/'); - self.emit_temp_buffer_chars(); - self.reconsume('>'); - self.state = State::ScriptDataEscaped; - } - } - Some(c) if c.is_ascii_alphabetic() => { - self.current_tag_name.push(c.to_ascii_lowercase()); - self.temp_buffer.push(c); - } - None => { - self.emit_char('<'); - self.emit_char('/'); - self.emit_temp_buffer_chars(); - self.state = State::ScriptDataEscaped; - } - Some(c) => { - self.emit_char('<'); - self.emit_char('/'); - self.emit_temp_buffer_chars(); - self.reconsume(c); - self.state = State::ScriptDataEscaped; - } - } - } - - // 13.2.5.26 Script data double escape start state - fn state_script_data_double_escape_start(&mut self) { - match self.consume() { - Some(c @ ('\t' | '\n' | '\x0C' | ' ' | '/' | '>')) => { - if self.temp_buffer == "script" { - self.state = State::ScriptDataDoubleEscaped; - } else { - self.state = State::ScriptDataEscaped; - } - self.emit_char(c); - } - Some(c) if c.is_ascii_alphabetic() => { - self.temp_buffer.push(c.to_ascii_lowercase()); - self.emit_char(c); - } - _ => { - if let Some(c) = self.input[self.pos..].chars().next() { - self.reconsume(c); - } - self.state = State::ScriptDataEscaped; - } - } - } - - // 13.2.5.27 Script data double escaped state - fn state_script_data_double_escaped(&mut self) { - match self.consume() { - Some('-') => { - self.state = State::ScriptDataDoubleEscapedDash; - self.emit_char('-'); - } - Some('<') => { - self.state = State::ScriptDataDoubleEscapedLessThanSign; - self.emit_char('<'); - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - self.emit_char('\u{FFFD}'); - } - None => { - self.emit_error("eof-in-script-html-comment-like-text"); - self.emit_eof(); - } - Some(c) => { - self.emit_char(c); - } - } - } - - // 13.2.5.28 Script data double escaped dash state - fn state_script_data_double_escaped_dash(&mut self) { - match self.consume() { - Some('-') => { - self.state = State::ScriptDataDoubleEscapedDashDash; - self.emit_char('-'); - } - Some('<') => { - self.state = State::ScriptDataDoubleEscapedLessThanSign; - self.emit_char('<'); - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - self.state = State::ScriptDataDoubleEscaped; - self.emit_char('\u{FFFD}'); - } - None => { - self.emit_error("eof-in-script-html-comment-like-text"); - self.emit_eof(); - } - Some(c) => { - self.state = State::ScriptDataDoubleEscaped; - self.emit_char(c); - } - } - } - - // 13.2.5.29 Script data double escaped dash dash state - fn state_script_data_double_escaped_dash_dash(&mut self) { - match self.consume() { - Some('-') => { - self.emit_char('-'); - } - Some('<') => { - self.state = State::ScriptDataDoubleEscapedLessThanSign; - self.emit_char('<'); - } - Some('>') => { - self.state = State::ScriptData; - self.emit_char('>'); - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - self.state = State::ScriptDataDoubleEscaped; - self.emit_char('\u{FFFD}'); - } - None => { - self.emit_error("eof-in-script-html-comment-like-text"); - self.emit_eof(); - } - Some(c) => { - self.state = State::ScriptDataDoubleEscaped; - self.emit_char(c); - } - } - } - - // 13.2.5.30 Script data double escaped less-than sign state - fn state_script_data_double_escaped_less_than_sign(&mut self) { - match self.peek() { - Some('/') => { - self.consume(); - self.temp_buffer.clear(); - self.state = State::ScriptDataDoubleEscapeEnd; - self.emit_char('/'); - } - _ => { - self.state = State::ScriptDataDoubleEscaped; - } - } - } - - // 13.2.5.31 Script data double escape end state - fn state_script_data_double_escape_end(&mut self) { - match self.consume() { - Some(c @ ('\t' | '\n' | '\x0C' | ' ' | '/' | '>')) => { - if self.temp_buffer == "script" { - self.state = State::ScriptDataEscaped; - } else { - self.state = State::ScriptDataDoubleEscaped; - } - self.emit_char(c); - } - Some(c) if c.is_ascii_alphabetic() => { - self.temp_buffer.push(c.to_ascii_lowercase()); - self.emit_char(c); - } - _ => { - if let Some(c) = self.input[self.pos..].chars().next() { - self.reconsume(c); - } - self.state = State::ScriptDataDoubleEscaped; - } - } - } - - // 13.2.5.32 Before attribute name state - fn state_before_attribute_name(&mut self) { - match self.consume() { - Some('\t' | '\n' | '\x0C' | ' ') => { - // Ignore whitespace. - } - Some('/' | '>') | None => { - if let Some(c) = self.input[self.pos.saturating_sub(1)..].chars().next() { - if c == '/' || c == '>' { - self.reconsume(c); - } - } - if self.pos == self.input.len() { - // EOF – reconsume handled by AfterAttributeName - } - self.state = State::AfterAttributeName; - } - Some('=') => { - self.emit_error("unexpected-equals-sign-before-attribute-name"); - self.start_new_attr(); - self.current_attr_name.push('='); - self.state = State::AttributeName; - } - Some(c) => { - self.start_new_attr(); - self.reconsume(c); - self.state = State::AttributeName; - } - } - } - - // 13.2.5.33 Attribute name state - fn state_attribute_name(&mut self) { - // Fast path: scan ahead for ASCII lowercase attribute name bytes. - let bytes = self.input.as_bytes(); - let start = self.pos; - let mut i = start; - while i < bytes.len() { - let b = bytes[i]; - match b { - b'\t' - | b'\n' - | 0x0C - | b' ' - | b'/' - | b'>' - | b'=' - | 0 - | b'"' - | b'\'' - | b'<' - | 0x80..=0xFF => break, - b'A'..=b'Z' => { - self.current_attr_name.push((b + 32) as char); - i += 1; - } - _ => { - self.current_attr_name.push(b as char); - i += 1; - } - } - } - self.pos = i; - - match self.consume() { - Some(c @ ('\t' | '\n' | '\x0C' | ' ' | '/' | '>')) => { - self.reconsume(c); - self.state = State::AfterAttributeName; - } - Some('=') => { - self.state = State::BeforeAttributeValue; - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - self.current_attr_name.push('\u{FFFD}'); - } - Some(c @ ('"' | '\'' | '<')) => { - self.emit_error("unexpected-character-in-attribute-name"); - self.current_attr_name.push(c); - } - None => { - self.reconsume('\0'); // will be handled by after-attr - self.pos = self.input.len(); // stay at EOF - self.state = State::AfterAttributeName; - } - Some(c) => { - self.current_attr_name.push(c.to_ascii_lowercase()); - } - } - } - - // 13.2.5.34 After attribute name state - fn state_after_attribute_name(&mut self) { - match self.consume() { - Some('\t' | '\n' | '\x0C' | ' ') => { - // Ignore. - } - Some('/') => { - self.state = State::SelfClosingStartTag; - } - Some('=') => { - self.state = State::BeforeAttributeValue; - } - Some('>') => { - self.state = State::Data; - self.emit_current_tag(); - } - None => { - self.emit_error("eof-in-tag"); - self.emit_eof(); - } - Some(c) => { - self.start_new_attr(); - self.reconsume(c); - self.state = State::AttributeName; - } - } - } - - // 13.2.5.35 Before attribute value state - fn state_before_attribute_value(&mut self) { - match self.consume() { - Some('\t' | '\n' | '\x0C' | ' ') => { - // Ignore. - } - Some('"') => { - self.state = State::AttributeValueDoubleQuoted; - } - Some('\'') => { - self.state = State::AttributeValueSingleQuoted; - } - Some('>') => { - self.emit_error("missing-attribute-value"); - self.state = State::Data; - self.emit_current_tag(); - } - Some(c) => { - self.reconsume(c); - self.state = State::AttributeValueUnquoted; - } - None => { - self.reconsume('\0'); - self.pos = self.input.len(); - self.state = State::AttributeValueUnquoted; - } - } - } - - // 13.2.5.36 Attribute value (double-quoted) state - fn state_attribute_value_double_quoted(&mut self) { - // Fast path: scan ahead for plain attribute value bytes (no ", &, \0). - let bytes = self.input.as_bytes(); - let start = self.pos; - let mut i = start; - while i < bytes.len() { - match bytes[i] { - b'"' | b'&' | 0 => break, - _ => i += 1, - } - } - if i > start { - self.current_attr_value.push_str(&self.input[start..i]); - self.pos = i; - } - - match self.consume() { - Some('"') => { - self.state = State::AfterAttributeValueQuoted; - } - Some('&') => { - self.return_state = State::AttributeValueDoubleQuoted; - self.state = State::CharacterReference; - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - self.current_attr_value.push('\u{FFFD}'); - } - None => { - self.emit_error("eof-in-tag"); - self.emit_eof(); - } - Some(c) => { - self.current_attr_value.push(c); - } - } - } - - // 13.2.5.37 Attribute value (single-quoted) state - fn state_attribute_value_single_quoted(&mut self) { - // Fast path: scan ahead for plain attribute value bytes (no ', &, \0). - let bytes = self.input.as_bytes(); - let start = self.pos; - let mut i = start; - while i < bytes.len() { - match bytes[i] { - b'\'' | b'&' | 0 => break, - _ => i += 1, - } - } - if i > start { - self.current_attr_value.push_str(&self.input[start..i]); - self.pos = i; - } - - match self.consume() { - Some('\'') => { - self.state = State::AfterAttributeValueQuoted; - } - Some('&') => { - self.return_state = State::AttributeValueSingleQuoted; - self.state = State::CharacterReference; - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - self.current_attr_value.push('\u{FFFD}'); - } - None => { - self.emit_error("eof-in-tag"); - self.emit_eof(); - } - Some(c) => { - self.current_attr_value.push(c); - } - } - } - - // 13.2.5.38 Attribute value (unquoted) state - fn state_attribute_value_unquoted(&mut self) { - match self.consume() { - Some('\t' | '\n' | '\x0C' | ' ') => { - self.state = State::BeforeAttributeName; - } - Some('&') => { - self.return_state = State::AttributeValueUnquoted; - self.state = State::CharacterReference; - } - Some('>') => { - self.state = State::Data; - self.emit_current_tag(); - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - self.current_attr_value.push('\u{FFFD}'); - } - Some(c @ ('"' | '\'' | '<' | '=' | '`')) => { - self.emit_error("unexpected-character-in-unquoted-attribute-value"); - self.current_attr_value.push(c); - } - None => { - self.emit_error("eof-in-tag"); - self.emit_eof(); - } - Some(c) => { - self.current_attr_value.push(c); - } - } - } - - // 13.2.5.39 After attribute value (quoted) state - fn state_after_attribute_value_quoted(&mut self) { - match self.consume() { - Some('\t' | '\n' | '\x0C' | ' ') => { - self.state = State::BeforeAttributeName; - } - Some('/') => { - self.state = State::SelfClosingStartTag; - } - Some('>') => { - self.state = State::Data; - self.emit_current_tag(); - } - None => { - self.emit_error("eof-in-tag"); - self.emit_eof(); - } - Some(c) => { - self.emit_error("missing-whitespace-between-attributes"); - self.reconsume(c); - self.state = State::BeforeAttributeName; - } - } - } - - // 13.2.5.40 Self-closing start tag state - fn state_self_closing_start_tag(&mut self) { - match self.consume() { - Some('>') => { - self.current_tag_self_closing = true; - self.state = State::Data; - self.emit_current_tag(); - } - None => { - self.emit_error("eof-in-tag"); - self.emit_eof(); - } - Some(c) => { - self.emit_error("unexpected-solidus-in-tag"); - self.reconsume(c); - self.state = State::BeforeAttributeName; - } - } - } - - // 13.2.5.41 Bogus comment state - fn state_bogus_comment(&mut self) { - match self.consume() { - Some('>') => { - self.state = State::Data; - self.emit_comment(); - } - None => { - self.emit_comment(); - self.emit_eof(); - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - self.current_comment.push('\u{FFFD}'); - } - Some(c) => { - self.current_comment.push(c); - } - } - } - - // 13.2.5.42 Markup declaration open state - fn state_markup_declaration_open(&mut self) { - if self.next_chars_are_ascii_ci("--") { - self.pos += 2; - self.current_comment.clear(); - self.state = State::CommentStart; - } else if self.next_chars_are_ascii_ci("DOCTYPE") { - self.pos += 7; - self.state = State::Doctype; - } else if self.next_chars_are_ascii_ci("[CDATA[") { - self.pos += 7; - if self.allow_cdata { - // In foreign content: treat as CDATA section. - self.state = State::CdataSection; - } else { - // In HTML content: parse error, treat as bogus comment. - self.emit_error("cdata-in-html-content"); - self.current_comment = "[CDATA[".to_string(); - self.state = State::BogusComment; - } - } else { - self.emit_error("incorrectly-opened-comment"); - self.current_comment.clear(); - self.state = State::BogusComment; - } - } - - // 13.2.5.43 Comment start state - fn state_comment_start(&mut self) { - match self.consume() { - Some('-') => { - self.state = State::CommentStartDash; - } - Some('>') => { - self.emit_error("abrupt-closing-of-empty-comment"); - self.state = State::Data; - self.emit_comment(); - } - Some(c) => { - self.reconsume(c); - self.state = State::Comment; - } - None => { - self.reconsume('\0'); - self.pos = self.input.len(); - self.state = State::Comment; - } - } - } - - // 13.2.5.44 Comment start dash state - fn state_comment_start_dash(&mut self) { - match self.consume() { - Some('-') => { - self.state = State::CommentEnd; - } - Some('>') => { - self.emit_error("abrupt-closing-of-empty-comment"); - self.state = State::Data; - self.emit_comment(); - } - None => { - self.emit_error("eof-in-comment"); - self.emit_comment(); - self.emit_eof(); - } - Some(c) => { - self.current_comment.push('-'); - self.reconsume(c); - self.state = State::Comment; - } - } - } - - // 13.2.5.45 Comment state - fn state_comment(&mut self) { - match self.consume() { - Some('<') => { - self.current_comment.push('<'); - self.state = State::CommentLessThanSign; - } - Some('-') => { - self.state = State::CommentEndDash; - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - self.current_comment.push('\u{FFFD}'); - } - None => { - self.emit_error("eof-in-comment"); - self.emit_comment(); - self.emit_eof(); - } - Some(c) => { - self.current_comment.push(c); - } - } - } - - // 13.2.5.46 Comment less-than sign state - fn state_comment_less_than_sign(&mut self) { - match self.consume() { - Some('!') => { - self.current_comment.push('!'); - self.state = State::CommentLessThanSignBang; - } - Some('<') => { - self.current_comment.push('<'); - } - Some(c) => { - self.reconsume(c); - self.state = State::Comment; - } - None => { - self.state = State::Comment; - } - } - } - - // 13.2.5.47 Comment less-than sign bang state - fn state_comment_less_than_sign_bang(&mut self) { - match self.peek() { - Some('-') => { - self.consume(); - self.state = State::CommentLessThanSignBangDash; - } - _ => { - self.state = State::Comment; - } - } - } - - // 13.2.5.48 Comment less-than sign bang dash state - fn state_comment_less_than_sign_bang_dash(&mut self) { - match self.peek() { - Some('-') => { - self.consume(); - self.state = State::CommentLessThanSignBangDashDash; - } - _ => { - self.state = State::CommentEndDash; - } - } - } - - // 13.2.5.49 Comment less-than sign bang dash dash state - fn state_comment_less_than_sign_bang_dash_dash(&mut self) { - match self.peek() { - Some('>') | None => { - self.state = State::CommentEnd; - } - _ => { - self.emit_error("nested-comment"); - self.state = State::CommentEnd; - } - } - } - - // 13.2.5.50 Comment end dash state - fn state_comment_end_dash(&mut self) { - match self.consume() { - Some('-') => { - self.state = State::CommentEnd; - } - None => { - self.emit_error("eof-in-comment"); - self.emit_comment(); - self.emit_eof(); - } - Some(c) => { - self.current_comment.push('-'); - self.reconsume(c); - self.state = State::Comment; - } - } - } - - // 13.2.5.51 Comment end state - fn state_comment_end(&mut self) { - match self.consume() { - Some('>') => { - self.state = State::Data; - self.emit_comment(); - } - Some('!') => { - self.state = State::CommentEndBang; - } - Some('-') => { - self.current_comment.push('-'); - } - None => { - self.emit_error("eof-in-comment"); - self.emit_comment(); - self.emit_eof(); - } - Some(c) => { - self.current_comment.push('-'); - self.current_comment.push('-'); - self.reconsume(c); - self.state = State::Comment; - } - } - } - - // 13.2.5.52 Comment end bang state - fn state_comment_end_bang(&mut self) { - match self.consume() { - Some('-') => { - self.current_comment.push('-'); - self.current_comment.push('-'); - self.current_comment.push('!'); - self.state = State::CommentEndDash; - } - Some('>') => { - self.emit_error("incorrectly-closed-comment"); - self.state = State::Data; - self.emit_comment(); - } - None => { - self.emit_error("eof-in-comment"); - self.emit_comment(); - self.emit_eof(); - } - Some(c) => { - self.current_comment.push('-'); - self.current_comment.push('-'); - self.current_comment.push('!'); - self.reconsume(c); - self.state = State::Comment; - } - } - } - - // 13.2.5.53 DOCTYPE state - fn state_doctype(&mut self) { - match self.consume() { - Some('\t' | '\n' | '\x0C' | ' ') => { - self.state = State::BeforeDoctypeName; - } - Some('>') => { - self.reconsume('>'); - self.state = State::BeforeDoctypeName; - } - None => { - self.emit_error("eof-in-doctype"); - self.create_doctype(); - self.current_doctype_force_quirks = true; - self.emit_doctype(); - self.emit_eof(); - } - Some(c) => { - self.emit_error("missing-whitespace-before-doctype-name"); - self.reconsume(c); - self.state = State::BeforeDoctypeName; - } - } - } - - // 13.2.5.54 Before DOCTYPE name state - fn state_before_doctype_name(&mut self) { - match self.consume() { - Some('\t' | '\n' | '\x0C' | ' ') => { - // Ignore. - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - self.create_doctype(); - self.current_doctype_name = Some(String::from('\u{FFFD}')); - self.state = State::DoctypeName; - } - Some('>') => { - self.emit_error("missing-doctype-name"); - self.create_doctype(); - self.current_doctype_force_quirks = true; - self.state = State::Data; - self.emit_doctype(); - } - None => { - self.emit_error("eof-in-doctype"); - self.create_doctype(); - self.current_doctype_force_quirks = true; - self.emit_doctype(); - self.emit_eof(); - } - Some(c) => { - self.create_doctype(); - self.current_doctype_name = Some(String::from(c.to_ascii_lowercase())); - self.state = State::DoctypeName; - } - } - } - - // 13.2.5.55 DOCTYPE name state - fn state_doctype_name(&mut self) { - match self.consume() { - Some('\t' | '\n' | '\x0C' | ' ') => { - self.state = State::AfterDoctypeName; - } - Some('>') => { - self.state = State::Data; - self.emit_doctype(); - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - if let Some(ref mut name) = self.current_doctype_name { - name.push('\u{FFFD}'); - } - } - None => { - self.emit_error("eof-in-doctype"); - self.current_doctype_force_quirks = true; - self.emit_doctype(); - self.emit_eof(); - } - Some(c) => { - if let Some(ref mut name) = self.current_doctype_name { - name.push(c.to_ascii_lowercase()); - } - } - } - } - - // 13.2.5.56 After DOCTYPE name state - fn state_after_doctype_name(&mut self) { - match self.consume() { - Some('\t' | '\n' | '\x0C' | ' ') => { - // Ignore. - } - Some('>') => { - self.state = State::Data; - self.emit_doctype(); - } - None => { - self.emit_error("eof-in-doctype"); - self.current_doctype_force_quirks = true; - self.emit_doctype(); - self.emit_eof(); - } - Some(c) => { - // Check for PUBLIC or SYSTEM keywords. - self.reconsume(c); - if self.next_chars_are_ascii_ci("PUBLIC") { - self.pos += 6; - self.state = State::AfterDoctypePublicKeyword; - } else if self.next_chars_are_ascii_ci("SYSTEM") { - self.pos += 6; - self.state = State::AfterDoctypeSystemKeyword; - } else { - self.consume(); // re-consume the char we put back - self.emit_error("invalid-character-sequence-after-doctype-name"); - self.current_doctype_force_quirks = true; - self.state = State::BogusDoctype; - } - } - } - } - - // 13.2.5.57 After DOCTYPE public keyword state - fn state_after_doctype_public_keyword(&mut self) { - match self.consume() { - Some('\t' | '\n' | '\x0C' | ' ') => { - self.state = State::BeforeDoctypePublicIdentifier; - } - Some('"') => { - self.emit_error("missing-whitespace-after-doctype-public-keyword"); - self.current_doctype_public_id = Some(String::new()); - self.state = State::DoctypePublicIdentifierDoubleQuoted; - } - Some('\'') => { - self.emit_error("missing-whitespace-after-doctype-public-keyword"); - self.current_doctype_public_id = Some(String::new()); - self.state = State::DoctypePublicIdentifierSingleQuoted; - } - Some('>') => { - self.emit_error("missing-doctype-public-identifier"); - self.current_doctype_force_quirks = true; - self.state = State::Data; - self.emit_doctype(); - } - None => { - self.emit_error("eof-in-doctype"); - self.current_doctype_force_quirks = true; - self.emit_doctype(); - self.emit_eof(); - } - Some(_) => { - self.emit_error("missing-quote-before-doctype-public-identifier"); - self.current_doctype_force_quirks = true; - self.state = State::BogusDoctype; - } - } - } - - // 13.2.5.58 Before DOCTYPE public identifier state - fn state_before_doctype_public_identifier(&mut self) { - match self.consume() { - Some('\t' | '\n' | '\x0C' | ' ') => { - // Ignore. - } - Some('"') => { - self.current_doctype_public_id = Some(String::new()); - self.state = State::DoctypePublicIdentifierDoubleQuoted; - } - Some('\'') => { - self.current_doctype_public_id = Some(String::new()); - self.state = State::DoctypePublicIdentifierSingleQuoted; - } - Some('>') => { - self.emit_error("missing-doctype-public-identifier"); - self.current_doctype_force_quirks = true; - self.state = State::Data; - self.emit_doctype(); - } - None => { - self.emit_error("eof-in-doctype"); - self.current_doctype_force_quirks = true; - self.emit_doctype(); - self.emit_eof(); - } - Some(_) => { - self.emit_error("missing-quote-before-doctype-public-identifier"); - self.current_doctype_force_quirks = true; - self.state = State::BogusDoctype; - } - } - } - - // 13.2.5.59 DOCTYPE public identifier (double-quoted) state - fn state_doctype_public_identifier_double_quoted(&mut self) { - match self.consume() { - Some('"') => { - self.state = State::AfterDoctypePublicIdentifier; - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - if let Some(ref mut id) = self.current_doctype_public_id { - id.push('\u{FFFD}'); - } - } - Some('>') => { - self.emit_error("abrupt-doctype-public-identifier"); - self.current_doctype_force_quirks = true; - self.state = State::Data; - self.emit_doctype(); - } - None => { - self.emit_error("eof-in-doctype"); - self.current_doctype_force_quirks = true; - self.emit_doctype(); - self.emit_eof(); - } - Some(c) => { - if let Some(ref mut id) = self.current_doctype_public_id { - id.push(c); - } - } - } - } - - // 13.2.5.60 DOCTYPE public identifier (single-quoted) state - fn state_doctype_public_identifier_single_quoted(&mut self) { - match self.consume() { - Some('\'') => { - self.state = State::AfterDoctypePublicIdentifier; - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - if let Some(ref mut id) = self.current_doctype_public_id { - id.push('\u{FFFD}'); - } - } - Some('>') => { - self.emit_error("abrupt-doctype-public-identifier"); - self.current_doctype_force_quirks = true; - self.state = State::Data; - self.emit_doctype(); - } - None => { - self.emit_error("eof-in-doctype"); - self.current_doctype_force_quirks = true; - self.emit_doctype(); - self.emit_eof(); - } - Some(c) => { - if let Some(ref mut id) = self.current_doctype_public_id { - id.push(c); - } - } - } - } - - // 13.2.5.61 After DOCTYPE public identifier state - fn state_after_doctype_public_identifier(&mut self) { - match self.consume() { - Some('\t' | '\n' | '\x0C' | ' ') => { - self.state = State::BetweenDoctypePublicAndSystemIdentifiers; - } - Some('>') => { - self.state = State::Data; - self.emit_doctype(); - } - Some('"') => { - self.emit_error("missing-whitespace-between-doctype-public-and-system-identifiers"); - self.current_doctype_system_id = Some(String::new()); - self.state = State::DoctypeSystemIdentifierDoubleQuoted; - } - Some('\'') => { - self.emit_error("missing-whitespace-between-doctype-public-and-system-identifiers"); - self.current_doctype_system_id = Some(String::new()); - self.state = State::DoctypeSystemIdentifierSingleQuoted; - } - None => { - self.emit_error("eof-in-doctype"); - self.current_doctype_force_quirks = true; - self.emit_doctype(); - self.emit_eof(); - } - Some(_) => { - self.emit_error("missing-quote-before-doctype-system-identifier"); - self.current_doctype_force_quirks = true; - self.state = State::BogusDoctype; - } - } - } - - // 13.2.5.62 Between DOCTYPE public and system identifiers state - fn state_between_doctype_public_and_system_identifiers(&mut self) { - match self.consume() { - Some('\t' | '\n' | '\x0C' | ' ') => { - // Ignore. - } - Some('>') => { - self.state = State::Data; - self.emit_doctype(); - } - Some('"') => { - self.current_doctype_system_id = Some(String::new()); - self.state = State::DoctypeSystemIdentifierDoubleQuoted; - } - Some('\'') => { - self.current_doctype_system_id = Some(String::new()); - self.state = State::DoctypeSystemIdentifierSingleQuoted; - } - None => { - self.emit_error("eof-in-doctype"); - self.current_doctype_force_quirks = true; - self.emit_doctype(); - self.emit_eof(); - } - Some(_) => { - self.emit_error("missing-quote-before-doctype-system-identifier"); - self.current_doctype_force_quirks = true; - self.state = State::BogusDoctype; - } - } - } - - // 13.2.5.63 After DOCTYPE system keyword state - fn state_after_doctype_system_keyword(&mut self) { - match self.consume() { - Some('\t' | '\n' | '\x0C' | ' ') => { - self.state = State::BeforeDoctypeSystemIdentifier; - } - Some('"') => { - self.emit_error("missing-whitespace-after-doctype-system-keyword"); - self.current_doctype_system_id = Some(String::new()); - self.state = State::DoctypeSystemIdentifierDoubleQuoted; - } - Some('\'') => { - self.emit_error("missing-whitespace-after-doctype-system-keyword"); - self.current_doctype_system_id = Some(String::new()); - self.state = State::DoctypeSystemIdentifierSingleQuoted; - } - Some('>') => { - self.emit_error("missing-doctype-system-identifier"); - self.current_doctype_force_quirks = true; - self.state = State::Data; - self.emit_doctype(); - } - None => { - self.emit_error("eof-in-doctype"); - self.current_doctype_force_quirks = true; - self.emit_doctype(); - self.emit_eof(); - } - Some(_) => { - self.emit_error("missing-quote-before-doctype-system-identifier"); - self.current_doctype_force_quirks = true; - self.state = State::BogusDoctype; - } - } - } - - // 13.2.5.64 Before DOCTYPE system identifier state - fn state_before_doctype_system_identifier(&mut self) { - match self.consume() { - Some('\t' | '\n' | '\x0C' | ' ') => { - // Ignore. - } - Some('"') => { - self.current_doctype_system_id = Some(String::new()); - self.state = State::DoctypeSystemIdentifierDoubleQuoted; - } - Some('\'') => { - self.current_doctype_system_id = Some(String::new()); - self.state = State::DoctypeSystemIdentifierSingleQuoted; - } - Some('>') => { - self.emit_error("missing-doctype-system-identifier"); - self.current_doctype_force_quirks = true; - self.state = State::Data; - self.emit_doctype(); - } - None => { - self.emit_error("eof-in-doctype"); - self.current_doctype_force_quirks = true; - self.emit_doctype(); - self.emit_eof(); - } - Some(_) => { - self.emit_error("missing-quote-before-doctype-system-identifier"); - self.current_doctype_force_quirks = true; - self.state = State::BogusDoctype; - } - } - } - - // 13.2.5.65 DOCTYPE system identifier (double-quoted) state - fn state_doctype_system_identifier_double_quoted(&mut self) { - match self.consume() { - Some('"') => { - self.state = State::AfterDoctypeSystemIdentifier; - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - if let Some(ref mut id) = self.current_doctype_system_id { - id.push('\u{FFFD}'); - } - } - Some('>') => { - self.emit_error("abrupt-doctype-system-identifier"); - self.current_doctype_force_quirks = true; - self.state = State::Data; - self.emit_doctype(); - } - None => { - self.emit_error("eof-in-doctype"); - self.current_doctype_force_quirks = true; - self.emit_doctype(); - self.emit_eof(); - } - Some(c) => { - if let Some(ref mut id) = self.current_doctype_system_id { - id.push(c); - } - } - } - } - - // 13.2.5.66 DOCTYPE system identifier (single-quoted) state - fn state_doctype_system_identifier_single_quoted(&mut self) { - match self.consume() { - Some('\'') => { - self.state = State::AfterDoctypeSystemIdentifier; - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - if let Some(ref mut id) = self.current_doctype_system_id { - id.push('\u{FFFD}'); - } - } - Some('>') => { - self.emit_error("abrupt-doctype-system-identifier"); - self.current_doctype_force_quirks = true; - self.state = State::Data; - self.emit_doctype(); - } - None => { - self.emit_error("eof-in-doctype"); - self.current_doctype_force_quirks = true; - self.emit_doctype(); - self.emit_eof(); - } - Some(c) => { - if let Some(ref mut id) = self.current_doctype_system_id { - id.push(c); - } - } - } - } - - // 13.2.5.67 After DOCTYPE system identifier state - fn state_after_doctype_system_identifier(&mut self) { - match self.consume() { - Some('\t' | '\n' | '\x0C' | ' ') => { - // Ignore. - } - Some('>') => { - self.state = State::Data; - self.emit_doctype(); - } - None => { - self.emit_error("eof-in-doctype"); - self.current_doctype_force_quirks = true; - self.emit_doctype(); - self.emit_eof(); - } - Some(_) => { - self.emit_error("unexpected-character-after-doctype-system-identifier"); - // Do NOT set force-quirks. - self.state = State::BogusDoctype; - } - } - } - - // 13.2.5.68 Bogus DOCTYPE state - fn state_bogus_doctype(&mut self) { - match self.consume() { - Some('>') => { - self.state = State::Data; - self.emit_doctype(); - } - Some('\0') => { - self.emit_error("unexpected-null-character"); - // Ignore. - } - None => { - self.emit_doctype(); - self.emit_eof(); - } - Some(_) => { - // Ignore. - } - } - } - - // 13.2.5.69 CDATA section state - fn state_cdata_section(&mut self) { - match self.consume() { - Some(']') => { - self.state = State::CdataSectionBracket; - } - None => { - self.emit_error("eof-in-cdata"); - self.emit_eof(); - } - Some(c) => { - self.emit_char(c); - } - } - } - - // 13.2.5.70 CDATA section bracket state - fn state_cdata_section_bracket(&mut self) { - if let Some(']') = self.peek() { - self.consume(); - self.state = State::CdataSectionEnd; - } else { - self.emit_char(']'); - self.state = State::CdataSection; - } - } - - // 13.2.5.71 CDATA section end state - fn state_cdata_section_end(&mut self) { - match self.peek() { - Some(']') => { - self.consume(); - self.emit_char(']'); - } - Some('>') => { - self.consume(); - self.state = State::Data; - } - _ => { - self.emit_char(']'); - self.emit_char(']'); - self.state = State::CdataSection; - } - } - } - - // 13.2.5.72 Character reference state - fn state_character_reference(&mut self) { - self.temp_buffer.clear(); - self.temp_buffer.push('&'); - match self.peek() { - Some(c) if c.is_ascii_alphanumeric() => { - self.state = State::NamedCharacterReference; - } - Some('#') => { - self.consume(); - self.temp_buffer.push('#'); - self.state = State::NumericCharacterReference; - } - _ => { - self.flush_code_points_consumed_as_char_ref(); - self.state = self.return_state; - } - } - } - - // 13.2.5.73 Named character reference state - fn state_named_character_reference(&mut self) { - use crate::html5::entities::is_legacy_named_entity; - - // The WHATWG spec says: consume the maximum number of characters - // possible where the consumed characters are one of the identifiers - // in the named character references table. The table has entries - // both with and without trailing semicolons. Entries without - // semicolons (the "legacy" set) may match even when no `;` follows; - // all other entries require the `;` to be present in the input. - let start = self.pos; - // (replacement, end_pos, had_semicolon) - let mut best_semicolon_match: Option<(&str, usize)> = None; - let mut best_legacy_match: Option<(&str, usize)> = None; - let mut name = String::new(); - - // Greedily consume characters that could be part of an entity name. - while let Some(c) = self.peek() { - if c.is_ascii_alphanumeric() { - name.push(c); - self.consume(); - if let Some(replacement) = lookup_entity(&name) { - if self.peek() == Some(';') { - self.consume(); - best_semicolon_match = Some((replacement, self.pos)); - // A semicolon match is always the best. Keep going - // would be past the `;`, so stop. - break; - } - // Without semicolon: only valid for legacy entities. - if is_legacy_named_entity(&name) { - best_legacy_match = Some((replacement, self.pos)); - } - } - } else { - break; - } - } - - // Prefer semicolon match, then legacy match, then no match. - let best_match = best_semicolon_match - .map(|(r, p)| (r, p, true)) - .or(best_legacy_match.map(|(r, p)| (r, p, false))); - - if let Some((replacement, end_pos, had_semicolon)) = best_match { - self.pos = end_pos; - - if !had_semicolon { - // Check if we are in an attribute value and the next char - // is `=` or alphanumeric — if so, treat as not a reference. - if is_attr_value_state(self.return_state) { - if let Some(next) = self.peek() { - if next == '=' || next.is_ascii_alphanumeric() { - self.pos = start; - self.flush_code_points_consumed_as_char_ref(); - self.state = self.return_state; - return; - } - } - } - self.emit_error("missing-semicolon-after-character-reference"); - } - - self.temp_buffer.clear(); - self.temp_buffer.push_str(replacement); - self.flush_code_points_consumed_as_char_ref(); - self.state = self.return_state; - } else { - // No match found — rewind to start. - self.pos = start; - self.flush_code_points_consumed_as_char_ref(); - self.state = State::AmbiguousAmpersand; - } - } - - // 13.2.5.74 Ambiguous ampersand state - fn state_ambiguous_ampersand(&mut self) { - match self.consume() { - Some(c) if c.is_ascii_alphanumeric() => { - if is_attr_value_state(self.return_state) { - self.current_attr_value.push(c); - } else { - self.emit_char(c); - } - } - Some(';') => { - self.emit_error("unknown-named-character-reference"); - self.reconsume(';'); - self.state = self.return_state; - } - Some(c) => { - self.reconsume(c); - self.state = self.return_state; - } - None => { - self.state = self.return_state; - } - } - } - - // 13.2.5.75 Numeric character reference state - fn state_numeric_character_reference(&mut self) { - self.char_ref_code = 0; - match self.peek() { - Some('x' | 'X') => { - let c = self.consume(); - if let Some(ch) = c { - self.temp_buffer.push(ch); - } - self.state = State::HexadecimalCharacterReferenceStart; - } - _ => { - self.state = State::DecimalCharacterReferenceStart; - } - } - } - - // 13.2.5.76 Hexadecimal character reference start state - fn state_hexadecimal_character_reference_start(&mut self) { - match self.peek() { - Some(c) if c.is_ascii_hexdigit() => { - self.state = State::HexadecimalCharacterReference; - } - _ => { - self.emit_error("absence-of-digits-in-numeric-character-reference"); - self.flush_code_points_consumed_as_char_ref(); - self.state = self.return_state; - } - } - } - - // 13.2.5.77 Decimal character reference start state - fn state_decimal_character_reference_start(&mut self) { - match self.peek() { - Some(c) if c.is_ascii_digit() => { - self.state = State::DecimalCharacterReference; - } - _ => { - self.emit_error("absence-of-digits-in-numeric-character-reference"); - self.flush_code_points_consumed_as_char_ref(); - self.state = self.return_state; - } - } - } - - // 13.2.5.78 Hexadecimal character reference state - fn state_hexadecimal_character_reference(&mut self) { - match self.consume() { - Some(c) if c.is_ascii_hexdigit() => { - self.char_ref_code = self - .char_ref_code - .saturating_mul(16) - .saturating_add(hex_digit_value(c)); - } - Some(';') => { - self.state = State::NumericCharacterReferenceEnd; - } - Some(c) => { - self.emit_error("missing-semicolon-after-character-reference"); - self.reconsume(c); - self.state = State::NumericCharacterReferenceEnd; - } - None => { - self.emit_error("missing-semicolon-after-character-reference"); - self.state = State::NumericCharacterReferenceEnd; - } - } - } - - // 13.2.5.79 Decimal character reference state - fn state_decimal_character_reference(&mut self) { - match self.consume() { - Some(c) if c.is_ascii_digit() => { - self.char_ref_code = self - .char_ref_code - .saturating_mul(10) - .saturating_add(u32::from(c as u8 - b'0')); - } - Some(';') => { - self.state = State::NumericCharacterReferenceEnd; - } - Some(c) => { - self.emit_error("missing-semicolon-after-character-reference"); - self.reconsume(c); - self.state = State::NumericCharacterReferenceEnd; - } - None => { - self.emit_error("missing-semicolon-after-character-reference"); - self.state = State::NumericCharacterReferenceEnd; - } - } - } - - // 13.2.5.80 Numeric character reference end state - fn state_numeric_character_reference_end(&mut self) { - let code = self.char_ref_code; - let ch = if code == 0 { - self.emit_error("null-character-reference"); - '\u{FFFD}' - } else if code > 0x10_FFFF { - self.emit_error("character-reference-outside-unicode-range"); - '\u{FFFD}' - } else if is_surrogate(code) { - self.emit_error("surrogate-character-reference"); - '\u{FFFD}' - } else if is_noncharacter(code) { - self.emit_error("noncharacter-character-reference"); - // The spec says to use the code point anyway for noncharacters. - char_from_u32(code) - } else if code == 0x0D || (is_control(code) && !is_ascii_whitespace_codepoint(code)) { - self.emit_error("control-character-reference"); - numeric_ref_replacement(code) - } else { - char_from_u32(code) - }; - - self.temp_buffer.clear(); - self.temp_buffer.push(ch); - self.flush_code_points_consumed_as_char_ref(); - self.state = self.return_state; - } -} - -// --------------------------------------------------------------------------- -// Free helper functions -// --------------------------------------------------------------------------- - -/// Returns true if the given state is one of the attribute value states. -fn is_attr_value_state(state: State) -> bool { - matches!( - state, - State::AttributeValueDoubleQuoted - | State::AttributeValueSingleQuoted - | State::AttributeValueUnquoted - ) -} - -/// Convert a hex digit character to its numeric value. -fn hex_digit_value(c: char) -> u32 { - match c { - '0'..='9' => u32::from(c as u8 - b'0'), - 'a'..='f' => u32::from(c as u8 - b'a') + 10, - 'A'..='F' => u32::from(c as u8 - b'A') + 10, - _ => 0, - } -} - -/// Convert a `u32` to a `char`, falling back to U+FFFD. -fn char_from_u32(code: u32) -> char { - char::from_u32(code).unwrap_or('\u{FFFD}') -} - -/// Is the code point a surrogate (U+D800..=U+DFFF)? -fn is_surrogate(code: u32) -> bool { - (0xD800..=0xDFFF).contains(&code) -} - -/// Is the code point a noncharacter? -fn is_noncharacter(code: u32) -> bool { - matches!( - code, - 0xFDD0 - ..=0xFDEF - | 0xFFFE - | 0xFFFF - | 0x1_FFFE - | 0x1_FFFF - | 0x2_FFFE - | 0x2_FFFF - | 0x3_FFFE - | 0x3_FFFF - | 0x4_FFFE - | 0x4_FFFF - | 0x5_FFFE - | 0x5_FFFF - | 0x6_FFFE - | 0x6_FFFF - | 0x7_FFFE - | 0x7_FFFF - | 0x8_FFFE - | 0x8_FFFF - | 0x9_FFFE - | 0x9_FFFF - | 0xA_FFFE - | 0xA_FFFF - | 0xB_FFFE - | 0xB_FFFF - | 0xC_FFFE - | 0xC_FFFF - | 0xD_FFFE - | 0xD_FFFF - | 0xE_FFFE - | 0xE_FFFF - | 0xF_FFFE - | 0xF_FFFF - | 0x10_FFFE - | 0x10_FFFF - ) -} - -/// Is the code point a control character (C0 or DEL range)? -fn is_control(code: u32) -> bool { - matches!(code, 0x00..=0x1F | 0x7F..=0x9F) -} - -/// Is the code point one of the ASCII whitespace code points? -fn is_ascii_whitespace_codepoint(code: u32) -> bool { - matches!(code, 0x09 | 0x0A | 0x0C | 0x0D | 0x20) -} - -/// The WHATWG numeric character reference replacement table (section 13.2.5.80). -/// -/// Certain control-character code points in the 0x80..=0x9F range are -/// replaced with Windows-1252 code points. -fn numeric_ref_replacement(code: u32) -> char { - match code { - 0x80 => '\u{20AC}', - 0x82 => '\u{201A}', - 0x83 => '\u{0192}', - 0x84 => '\u{201E}', - 0x85 => '\u{2026}', - 0x86 => '\u{2020}', - 0x87 => '\u{2021}', - 0x88 => '\u{02C6}', - 0x89 => '\u{2030}', - 0x8A => '\u{0160}', - 0x8B => '\u{2039}', - 0x8C => '\u{0152}', - 0x8E => '\u{017D}', - 0x91 => '\u{2018}', - 0x92 => '\u{2019}', - 0x93 => '\u{201C}', - 0x94 => '\u{201D}', - 0x95 => '\u{2022}', - 0x96 => '\u{2013}', - 0x97 => '\u{2014}', - 0x98 => '\u{02DC}', - 0x99 => '\u{2122}', - 0x9A => '\u{0161}', - 0x9B => '\u{203A}', - 0x9C => '\u{0153}', - 0x9E => '\u{017E}', - 0x9F => '\u{0178}', - _ => char_from_u32(code), - } -} - -/// Normalize newlines per WHATWG spec §13.2.3. -/// -/// Replace every CR (U+000D) and every CR+LF pair with a single LF (U+000A). -fn normalize_newlines(input: &str) -> String { - let mut result = String::with_capacity(input.len()); - let mut chars = input.chars().peekable(); - while let Some(c) = chars.next() { - if c == '\r' { - result.push('\n'); - if chars.peek() == Some(&'\n') { - chars.next(); - } - } else { - result.push(c); - } - } - result -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - - /// Collect all tokens from a tokenizer. - fn tokenize(input: &str) -> Vec<Token> { - let mut tok = Tokenizer::new(input); - let mut tokens = Vec::new(); - loop { - let t = tok.next_token(); - if t == Token::Eof { - tokens.push(t); - break; - } - tokens.push(t); - } - tokens - } - - /// Collect all non-EOF tokens. - fn tokenize_body(input: &str) -> Vec<Token> { - tokenize(input) - .into_iter() - .filter(|t| *t != Token::Eof) - .collect() - } - - /// Helper: collect just the errors. - fn tokenize_errors(input: &str) -> Vec<String> { - let mut tok = Tokenizer::new(input); - loop { - if tok.next_token() == Token::Eof { - break; - } - } - tok.errors().iter().map(|e| e.code.to_string()).collect() - } - - #[test] - fn test_basic_start_tag() { - let tokens = tokenize_body("<div>"); - assert_eq!( - tokens, - vec![Token::StartTag { - name: "div".into(), - attributes: vec![], - self_closing: false, - }] - ); - } - - #[test] - fn test_basic_end_tag() { - let tokens = tokenize_body("</div>"); - assert_eq!(tokens, vec![Token::EndTag { name: "div".into() }]); - } - - #[test] - fn test_self_closing_tag() { - let tokens = tokenize_body("<br/>"); - assert_eq!( - tokens, - vec![Token::StartTag { - name: "br".into(), - attributes: vec![], - self_closing: true, - }] - ); - } - - #[test] - fn test_tag_with_attributes() { - let tokens = tokenize_body(r#"<div class="main" id='app'>"#); - assert_eq!( - tokens, - vec![Token::StartTag { - name: "div".into(), - attributes: vec![ - Attribute { - name: "class".into(), - value: "main".into(), - }, - Attribute { - name: "id".into(), - value: "app".into(), - }, - ], - self_closing: false, - }] - ); - } - - #[test] - fn test_unquoted_attribute() { - let tokens = tokenize_body("<div class=main>"); - assert_eq!( - tokens, - vec![Token::StartTag { - name: "div".into(), - attributes: vec![Attribute { - name: "class".into(), - value: "main".into(), - }], - self_closing: false, - }] - ); - } - - #[test] - fn test_boolean_attribute() { - let tokens = tokenize_body("<input disabled>"); - assert_eq!( - tokens, - vec![Token::StartTag { - name: "input".into(), - attributes: vec![Attribute { - name: "disabled".into(), - value: String::new(), - }], - self_closing: false, - }] - ); - } - - #[test] - fn test_comment() { - let tokens = tokenize_body("<!-- hello -->"); - assert_eq!(tokens, vec![Token::Comment(" hello ".into())]); - } - - #[test] - fn test_doctype() { - let tokens = tokenize_body("<!DOCTYPE html>"); - assert_eq!( - tokens, - vec![Token::Doctype { - name: Some("html".into()), - public_id: None, - system_id: None, - force_quirks: false, - }] - ); - } - - #[test] - fn test_doctype_with_public_system() { - let tokens = tokenize_body( - r#"<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">"#, - ); - assert_eq!( - tokens, - vec![Token::Doctype { - name: Some("html".into()), - public_id: Some("-//W3C//DTD HTML 4.01//EN".into()), - system_id: Some("http://www.w3.org/TR/html4/strict.dtd".into()), - force_quirks: false, - }] - ); - } - - #[test] - fn test_character_data() { - let tokens = tokenize_body("hello"); - assert_eq!( - tokens, - vec![ - Token::Character('h'), - Token::Character('e'), - Token::Character('l'), - Token::Character('l'), - Token::Character('o'), - ] - ); - } - - #[test] - fn test_named_character_reference() { - let tokens = tokenize_body("&amp;"); - assert_eq!(tokens, vec![Token::Character('&')]); - } - - #[test] - fn test_named_character_reference_nbsp() { - let tokens = tokenize_body("&nbsp;"); - assert_eq!(tokens, vec![Token::Character('\u{00A0}')]); - } - - #[test] - fn test_numeric_decimal_reference() { - let tokens = tokenize_body("&#65;"); - assert_eq!(tokens, vec![Token::Character('A')]); - } - - #[test] - fn test_numeric_hex_reference() { - let tokens = tokenize_body("&#x41;"); - assert_eq!(tokens, vec![Token::Character('A')]); - } - - #[test] - fn test_numeric_hex_reference_uppercase() { - let tokens = tokenize_body("&#X41;"); - assert_eq!(tokens, vec![Token::Character('A')]); - } - - #[test] - fn test_numeric_reference_replacement_table() { - // &#128; (0x80) should map to Euro sign U+20AC - let tokens = tokenize_body("&#128;"); - assert_eq!(tokens, vec![Token::Character('\u{20AC}')]); - } - - #[test] - fn test_numeric_reference_null() { - // &#0; should map to U+FFFD - let tokens = tokenize_body("&#0;"); - assert_eq!(tokens, vec![Token::Character('\u{FFFD}')]); - } - - #[test] - fn test_set_state_rawtext() { - let mut tok = Tokenizer::new("<div>ignored</div>"); - // Simulate tree builder switching to RawText after seeing a style tag. - tok.set_state(State::RawText); - // In RawText, everything is character tokens until `</` + matching tag. - let first = tok.next_token(); - assert_eq!(first, Token::Character('<')); - } - - #[test] - fn test_set_state_rcdata() { - let mut tok = Tokenizer::new("hello &amp; world"); - tok.set_state(State::RcData); - let mut chars = String::new(); - loop { - match tok.next_token() { - Token::Character(c) => chars.push(c), - Token::Eof => break, - _ => {} - } - } - assert_eq!(chars, "hello & world"); - } - - #[test] - fn test_eof_in_tag_error() { - let errors = tokenize_errors("<div"); - assert!(errors.contains(&"eof-in-tag".to_string())); - } - - #[test] - fn test_eof_in_comment_error() { - let errors = tokenize_errors("<!-- unclosed"); - assert!(errors.contains(&"eof-in-comment".to_string())); - } - - #[test] - fn test_missing_attribute_value_error() { - let errors = tokenize_errors("<div class=>"); - assert!(errors.contains(&"missing-attribute-value".to_string())); - } - - #[test] - fn test_eof_before_tag_name() { - let tokens = tokenize_body("<"); - // Should emit '<' as character, then EOF (which we filter). - assert_eq!(tokens, vec![Token::Character('<')]); - } - - #[test] - fn test_duplicate_attributes_ignored() { - let tokens = tokenize_body(r#"<div a="1" a="2">"#); - assert_eq!( - tokens, - vec![Token::StartTag { - name: "div".into(), - attributes: vec![Attribute { - name: "a".into(), - value: "1".into(), - }], - self_closing: false, - }] - ); - } - - #[test] - fn test_tag_name_case_lowered() { - let tokens = tokenize_body("<DIV>"); - assert_eq!( - tokens, - vec![Token::StartTag { - name: "div".into(), - attributes: vec![], - self_closing: false, - }] - ); - } - - #[test] - fn test_cdata_section() { - // In non-foreign content (allow_cdata=false), CDATA is treated as bogus comment. - let tokens = tokenize_body("<![CDATA[hello]]>"); - assert_eq!(tokens, vec![Token::Comment("[CDATA[hello]]".into())]); - } - - #[test] - fn test_cdata_section_in_foreign_content() { - // When allow_cdata=true, CDATA content is emitted as character tokens. - let mut tok = Tokenizer::new("<![CDATA[hello]]>"); - tok.set_allow_cdata(true); - let mut chars = String::new(); - loop { - match tok.next_token() { - Token::Character(c) => chars.push(c), - Token::Eof => break, - _ => {} - } - } - assert_eq!(chars, "hello"); - } - - #[test] - fn test_bogus_comment_from_question_mark() { - let tokens = tokenize_body("<?xml version='1.0'?>"); - // Should be treated as a bogus comment. - assert_eq!(tokens, vec![Token::Comment("?xml version='1.0'?".into())]); - } - - #[test] - fn test_null_in_data_emitted() { - // Per WHATWG spec, Data state emits null as-is (with parse error). - let tokens = tokenize_body("\0"); - assert_eq!(tokens, vec![Token::Character('\0')]); - } - - #[test] - fn test_empty_input() { - let tokens = tokenize(""); - assert_eq!(tokens, vec![Token::Eof]); - } - - #[test] - fn test_multiple_attributes_mixed_quoting() { - let tokens = tokenize_body(r#"<a href="url" target=_blank title='tip'>"#); - assert_eq!( - tokens, - vec![Token::StartTag { - name: "a".into(), - attributes: vec![ - Attribute { - name: "href".into(), - value: "url".into(), - }, - Attribute { - name: "target".into(), - value: "_blank".into(), - }, - Attribute { - name: "title".into(), - value: "tip".into(), - }, - ], - self_closing: false, - }] - ); - } - - #[test] - fn test_character_reference_in_attribute() { - let tokens = tokenize_body(r#"<a href="?a=1&amp;b=2">"#); - assert_eq!( - tokens, - vec![Token::StartTag { - name: "a".into(), - attributes: vec![Attribute { - name: "href".into(), - value: "?a=1&b=2".into(), - }], - self_closing: false, - }] - ); - } - - #[test] - fn test_abrupt_closing_of_empty_comment() { - let tokens = tokenize_body("<!-->"); - assert_eq!(tokens, vec![Token::Comment(String::new())]); - let errors = tokenize_errors("<!-->"); - assert!(errors.contains(&"abrupt-closing-of-empty-comment".to_string())); - } - - #[test] - fn test_incorrectly_opened_comment() { - let tokens = tokenize_body("<!foo>"); - assert_eq!(tokens, vec![Token::Comment("foo".into())]); - let errors = tokenize_errors("<!foo>"); - assert!(errors.contains(&"incorrectly-opened-comment".to_string())); - } - - #[test] - fn test_eof_in_doctype() { - let tokens = tokenize_body("<!DOCTYPE"); - assert_eq!( - tokens, - vec![Token::Doctype { - name: None, - public_id: None, - system_id: None, - force_quirks: true, - }] - ); - let errors = tokenize_errors("<!DOCTYPE"); - assert!(errors.contains(&"eof-in-doctype".to_string())); - } -} diff --git a/browser/vendor/xmloxide/src/html5/tree_builder.rs b/browser/vendor/xmloxide/src/html5/tree_builder.rs deleted file mode 100644 index 89dcf1652..000000000 --- a/browser/vendor/xmloxide/src/html5/tree_builder.rs +++ /dev/null @@ -1,4721 +0,0 @@ -//! WHATWG HTML5 tree construction algorithm. -//! -//! This module implements the tree construction stage of the HTML parsing -//! algorithm as defined in the WHATWG HTML Living Standard. It consumes -//! tokens from the [`Tokenizer`] and builds a [`Document`] tree. -//! -//! See <https://html.spec.whatwg.org/multipage/parsing.html#tree-construction> - -use crate::error::{ErrorSeverity, ParseDiagnostic, ParseError, SourceLocation}; -use crate::html5::tokenizer::{self, State, Token, Tokenizer}; -use crate::tree::{Document, NodeId, NodeKind}; - -// --------------------------------------------------------------------------- -// Insertion mode -// --------------------------------------------------------------------------- - -/// All insertion modes from the WHATWG specification. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum InsertionMode { - Initial, - BeforeHtml, - BeforeHead, - InHead, - InHeadNoscript, - AfterHead, - InBody, - Text, - InTable, - InTableText, - InCaption, - InColumnGroup, - InTableBody, - InRow, - InCell, - InSelect, - InSelectInTable, - InTemplate, - AfterBody, - InFrameset, - AfterFrameset, - AfterAfterBody, - AfterAfterFrameset, -} - -// --------------------------------------------------------------------------- -// Quirks mode -// --------------------------------------------------------------------------- - -/// Document compatibility mode determined by the DOCTYPE. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum QuirksMode { - NoQuirks, - Quirks, - LimitedQuirks, -} - -// Full quirks mode public identifier prefixes -const QUIRKS_PREFIXES: &[&str] = &[ - "+//silmaril//dtd html pro v0r11 19970101//", - "-//as//dtd html 3.0 aswedit extensions//", - "-//advasoft ltd//dtd html 3.0 aswedit + extensions//", - "-//ietf//dtd html 2.0 level 1//", - "-//ietf//dtd html 2.0 level 2//", - "-//ietf//dtd html 2.0 strict level 1//", - "-//ietf//dtd html 2.0 strict level 2//", - "-//ietf//dtd html 2.0 strict//", - "-//ietf//dtd html 2.0//", - "-//ietf//dtd html 2.1e//", - "-//ietf//dtd html 3.0//", - "-//ietf//dtd html 3.2 final//", - "-//ietf//dtd html 3.2//", - "-//ietf//dtd html 3//", - "-//ietf//dtd html level 0//", - "-//ietf//dtd html level 1//", - "-//ietf//dtd html level 2//", - "-//ietf//dtd html level 3//", - "-//ietf//dtd html strict level 0//", - "-//ietf//dtd html strict level 1//", - "-//ietf//dtd html strict level 2//", - "-//ietf//dtd html strict level 3//", - "-//ietf//dtd html strict//", - "-//ietf//dtd html//", - "-//metrius//dtd metrius presentational//", - "-//microsoft//dtd internet explorer 2.0 html strict//", - "-//microsoft//dtd internet explorer 2.0 html//", - "-//microsoft//dtd internet explorer 2.0 tables//", - "-//microsoft//dtd internet explorer 3.0 html strict//", - "-//microsoft//dtd internet explorer 3.0 html//", - "-//microsoft//dtd internet explorer 3.0 tables//", - "-//netscape comm. corp.//dtd html//", - "-//netscape comm. corp.//dtd strict html//", - "-//o'reilly and associates//dtd html 2.0//", - "-//o'reilly and associates//dtd html extended 1.0//", - "-//o'reilly and associates//dtd html extended relaxed 1.0//", - "-//sq//dtd html 2.0 hotmetal + extensions//", - "-//softquad software//dtd hotmetal pro 6.0::19990601::extensions to html 4.0//", - "-//softquad//dtd hotmetal pro 4.0::19971010::extensions to html 4.0//", - "-//spyglass//dtd html 2.0 extended//", - "-//sun microsystems corp.//dtd hotjava html//", - "-//sun microsystems corp.//dtd hotjava strict html//", - "-//w3c//dtd html 3 1995-03-24//", - "-//w3c//dtd html 3.2 draft//", - "-//w3c//dtd html 3.2 final//", - "-//w3c//dtd html 3.2//", - "-//w3c//dtd html 3.2s draft//", - "-//w3c//dtd html 4.0 frameset//", - "-//w3c//dtd html 4.0 transitional//", - "-//w3c//dtd html experimental 19960712//", - "-//w3c//dtd html experimental 970421//", - "-//w3c//dtd w3 html//", - "-//w3o//dtd w3 html 3.0//", - "-//webtechs//dtd mozilla html 2.0//", - "-//webtechs//dtd mozilla html//", -]; - -// Exact matches for quirks -const QUIRKS_EXACT: &[&str] = &[ - "-//w3o//dtd w3 html strict 3.0//en//", - "-/w3c/dtd html 4.0 transitional/en", - "html", -]; - -/// Determine quirks mode from a DOCTYPE token per WHATWG §13.2.6.4.1. -fn determine_quirks_mode( - name: &str, - public_id: Option<&str>, - system_id: Option<&str>, - force_quirks: bool, -) -> QuirksMode { - if force_quirks { - return QuirksMode::Quirks; - } - if !name.eq_ignore_ascii_case("html") { - return QuirksMode::Quirks; - } - let pub_id = public_id.unwrap_or(""); - let pub_lower = pub_id.to_ascii_lowercase(); - - let sys_id = system_id.unwrap_or(""); - let sys_lower = sys_id.to_ascii_lowercase(); - - if sys_lower == "http://www.ibm.com/data/dtd/v11/ibmxhtml1-transitional.dtd" { - return QuirksMode::Quirks; - } - - for exact in QUIRKS_EXACT { - if pub_lower == *exact { - return QuirksMode::Quirks; - } - } - - for prefix in QUIRKS_PREFIXES { - if pub_lower.starts_with(prefix) { - return QuirksMode::Quirks; - } - } - - // Quirks if these prefixes appear and system identifier is missing - if system_id.is_none() - && (pub_lower.starts_with("-//w3c//dtd html 4.01 frameset//") - || pub_lower.starts_with("-//w3c//dtd html 4.01 transitional//")) - { - return QuirksMode::Quirks; - } - - // Limited quirks mode - if pub_lower.starts_with("-//w3c//dtd xhtml 1.0 frameset//") - || pub_lower.starts_with("-//w3c//dtd xhtml 1.0 transitional//") - { - return QuirksMode::LimitedQuirks; - } - if system_id.is_some() - && (pub_lower.starts_with("-//w3c//dtd html 4.01 frameset//") - || pub_lower.starts_with("-//w3c//dtd html 4.01 transitional//")) - { - return QuirksMode::LimitedQuirks; - } - - QuirksMode::NoQuirks -} - -// --------------------------------------------------------------------------- -// Namespace -// --------------------------------------------------------------------------- - -/// The namespace of an element in the tree. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Namespace { - Html, - Svg, - MathMl, -} - -impl Namespace { - fn uri(self) -> &'static str { - match self { - Self::Html => "http://www.w3.org/1999/xhtml", - Self::Svg => "http://www.w3.org/2000/svg", - Self::MathMl => "http://www.w3.org/1998/Math/MathML", - } - } -} - -// --------------------------------------------------------------------------- -// Active formatting list entry -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone)] -enum FormatEntry { - Element { - node_id: NodeId, - name: String, - attrs: Vec<tokenizer::Attribute>, - }, - Marker, -} - -// --------------------------------------------------------------------------- -// Tree build error -// --------------------------------------------------------------------------- - -/// An error encountered during tree construction. -#[derive(Debug, Clone)] -#[allow(dead_code)] -struct TreeBuildError { - message: String, -} - -// --------------------------------------------------------------------------- -// Helper: element metadata stored alongside NodeId on the open elements stack -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone)] -struct StackEntry { - node_id: NodeId, - name: String, - ns: Namespace, - /// True if this is a `MathML` `annotation-xml` element with - /// `encoding="text/html"` or `encoding="application/xhtml+xml"`. - is_html_integration: bool, -} - -// --------------------------------------------------------------------------- -// Options -// --------------------------------------------------------------------------- - -/// Options for HTML5 parsing. -#[derive(Debug, Clone, Default)] -pub struct Html5ParseOptions { - /// Whether scripting is enabled (affects `<noscript>` handling). - pub scripting: bool, - /// If set, parse as a fragment with the given context element tag name. - /// - /// Use plain element names for HTML contexts (e.g. `"body"`, `"select"`) - /// and namespace-prefixed names for foreign contexts (e.g. `"svg svg"`, - /// `"math mi"`). - pub fragment_context: Option<String>, -} - -/// Result of HTML5 parsing, containing the document tree and any parse errors. -/// -/// The WHATWG HTML5 parsing algorithm is designed to handle all input without -/// fatal errors. Parse errors are collected as diagnostics rather than causing -/// failure; the tree is always produced. -/// -/// # Examples -/// -/// ``` -/// use xmloxide::html5::parse_html5_full; -/// -/// let result = parse_html5_full("<p>Unclosed paragraph<p>Next"); -/// assert!(result.errors.is_empty() || !result.errors.is_empty()); // always succeeds -/// let _doc = result.document; // tree is always available -/// ``` -#[derive(Debug)] -pub struct Html5ParseResult { - /// The constructed document tree. - pub document: Document, - /// Parse errors encountered during tokenization and tree construction. - /// - /// These are non-fatal diagnostics — the document tree is still complete. - pub errors: Vec<ParseDiagnostic>, -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/// Parses an HTML5 string into a [`Document`] with default options. -/// -/// # Errors -/// -/// Returns [`ParseError`] if parsing fails fatally (extremely rare for HTML5, -/// since the algorithm is designed to handle all input). -/// -/// # Examples -/// -/// ``` -/// use xmloxide::html5::parse_html5; -/// -/// let doc = parse_html5("<h1>Hello</h1>").unwrap(); -/// let root = doc.root_element().unwrap(); -/// assert_eq!(doc.node_name(root), Some("html")); -/// ``` -pub fn parse_html5(input: &str) -> Result<Document, ParseError> { - parse_html5_with_options(input, &Html5ParseOptions::default()) -} - -/// Parses an HTML5 string into a [`Document`] with the given options. -/// -/// # Errors -/// -/// Returns [`ParseError`] if parsing fails fatally. -/// -/// # Examples -/// -/// ``` -/// use xmloxide::html5::{parse_html5_with_options, Html5ParseOptions}; -/// -/// let opts = Html5ParseOptions { -/// scripting: false, -/// fragment_context: Some("body".to_string()), -/// }; -/// let doc = parse_html5_with_options("<p>fragment</p>", &opts).unwrap(); -/// ``` -pub fn parse_html5_with_options( - input: &str, - options: &Html5ParseOptions, -) -> Result<Document, ParseError> { - let result = parse_html5_full_with_options(input, options); - Ok(result.document) -} - -/// Parses an HTML5 string and returns the document tree along with all parse -/// errors. -/// -/// Unlike [`parse_html5`], this function always succeeds (no `Result`) and -/// returns collected [`ParseDiagnostic`]s for inspection. -/// -/// # Examples -/// -/// ``` -/// use xmloxide::html5::parse_html5_full; -/// -/// let result = parse_html5_full("<p>Hello</p>"); -/// println!("{} errors", result.errors.len()); -/// ``` -pub fn parse_html5_full(input: &str) -> Html5ParseResult { - parse_html5_full_with_options(input, &Html5ParseOptions::default()) -} - -/// Parses an HTML5 string with the given options, returning the document tree -/// and all parse errors. -pub fn parse_html5_full_with_options(input: &str, options: &Html5ParseOptions) -> Html5ParseResult { - let tokenizer = Tokenizer::new(input); - let mut builder = TreeBuilder::new(tokenizer, options); - builder.run(); - - // Collect tokenizer errors as diagnostics. - let mut errors: Vec<ParseDiagnostic> = builder - .tokenizer - .errors() - .iter() - .map(|e| { - // Compute line/column from byte offset. - let (line, col) = byte_offset_to_line_col(input, e.span); - ParseDiagnostic { - severity: ErrorSeverity::Error, - message: e.code.to_string(), - location: SourceLocation { - line, - column: col, - byte_offset: e.span, - }, - } - }) - .collect(); - - // Append tree construction errors. - for e in &builder.errors { - errors.push(ParseDiagnostic { - severity: ErrorSeverity::Error, - message: e.message.clone(), - location: SourceLocation::default(), - }); - } - - Html5ParseResult { - document: builder.doc, - errors, - } -} - -/// Convert a byte offset into 1-based (line, column) pair. -fn byte_offset_to_line_col(input: &str, offset: usize) -> (u32, u32) { - let bytes = input.as_bytes(); - let end = offset.min(bytes.len()); - let mut line: u32 = 1; - let mut col: u32 = 1; - for &b in &bytes[..end] { - if b == b'\n' { - line += 1; - col = 1; - } else { - col += 1; - } - } - (line, col) -} - -// --------------------------------------------------------------------------- -// Constants: element categories -// --------------------------------------------------------------------------- - -fn is_formatting_element(name: &str) -> bool { - matches!( - name, - "a" | "b" - | "big" - | "code" - | "em" - | "font" - | "i" - | "s" - | "small" - | "strike" - | "strong" - | "tt" - | "u" - | "nobr" - ) -} - -fn is_special_element_ns(name: &str, ns: Namespace) -> bool { - match ns { - Namespace::Html => matches!( - name, - "address" - | "applet" - | "area" - | "article" - | "aside" - | "base" - | "basefont" - | "bgsound" - | "blockquote" - | "body" - | "br" - | "button" - | "caption" - | "center" - | "col" - | "colgroup" - | "dd" - | "details" - | "dir" - | "div" - | "dl" - | "dt" - | "embed" - | "fieldset" - | "figcaption" - | "figure" - | "footer" - | "form" - | "frame" - | "frameset" - | "h1" - | "h2" - | "h3" - | "h4" - | "h5" - | "h6" - | "head" - | "header" - | "hgroup" - | "hr" - | "html" - | "iframe" - | "img" - | "input" - | "keygen" - | "li" - | "link" - | "listing" - | "main" - | "marquee" - | "menu" - | "meta" - | "nav" - | "noembed" - | "noframes" - | "noscript" - | "object" - | "ol" - | "p" - | "param" - | "plaintext" - | "pre" - | "script" - | "search" - | "section" - | "select" - | "source" - | "style" - | "summary" - | "table" - | "tbody" - | "td" - | "template" - | "textarea" - | "tfoot" - | "th" - | "thead" - | "title" - | "tr" - | "track" - | "ul" - | "wbr" - | "xmp" - ), - Namespace::MathMl => { - matches!(name, "mi" | "mo" | "mn" | "ms" | "mtext" | "annotation-xml") - } - Namespace::Svg => matches!(name, "foreignObject" | "desc" | "title"), - } -} - -#[allow(dead_code)] -fn is_void_element(name: &str) -> bool { - matches!( - name, - "area" - | "base" - | "br" - | "col" - | "embed" - | "hr" - | "img" - | "input" - | "link" - | "meta" - | "param" - | "source" - | "track" - | "wbr" - ) -} - -fn is_heading(name: &str) -> bool { - matches!(name, "h1" | "h2" | "h3" | "h4" | "h5" | "h6") -} - -// Scope element sets (WHATWG 13.2.4.2) -fn is_scope_element(name: &str, ns: Namespace) -> bool { - match ns { - Namespace::Html => matches!( - name, - "applet" - | "caption" - | "html" - | "table" - | "td" - | "th" - | "marquee" - | "object" - | "template" - ), - Namespace::MathMl => matches!(name, "mi" | "mo" | "mn" | "ms" | "mtext" | "annotation-xml"), - Namespace::Svg => matches!(name, "foreignObject" | "desc" | "title"), - } -} - -fn is_list_item_scope_element(name: &str, ns: Namespace) -> bool { - is_scope_element(name, ns) || (ns == Namespace::Html && matches!(name, "ol" | "ul")) -} - -fn is_button_scope_element(name: &str, ns: Namespace) -> bool { - is_scope_element(name, ns) || (ns == Namespace::Html && name == "button") -} - -fn is_table_scope_element(name: &str, ns: Namespace) -> bool { - ns == Namespace::Html && matches!(name, "html" | "table" | "template") -} - -fn is_select_scope_element(name: &str, ns: Namespace) -> bool { - // Everything EXCEPT optgroup, option, and elements allowed in the new - // select content model. - !(ns == Namespace::Html - && matches!( - name, - "optgroup" - | "option" - | "button" - | "datalist" - | "div" - | "selectedcontent" - | "b" - | "big" - | "code" - | "em" - | "font" - | "i" - | "s" - | "small" - | "strike" - | "strong" - | "tt" - | "u" - | "a" - | "nobr" - | "keygen" - | "menuitem" - | "hr" - | "img" - | "br" - | "p" - | "span" - | "label" - )) -} - -/// Tags that break out of foreign content back to HTML processing. -fn is_foreign_breakout_tag(name: &str) -> bool { - matches!( - name, - "b" | "big" - | "blockquote" - | "body" - | "br" - | "center" - | "code" - | "dd" - | "details" - | "dialog" - | "dir" - | "div" - | "dl" - | "dt" - | "em" - | "embed" - | "h1" - | "h2" - | "h3" - | "h4" - | "h5" - | "h6" - | "head" - | "hr" - | "i" - | "img" - | "li" - | "listing" - | "menu" - | "meta" - | "nobr" - | "ol" - | "p" - | "pre" - | "ruby" - | "s" - | "small" - | "span" - | "strong" - | "strike" - | "sub" - | "sup" - | "table" - | "tt" - | "u" - | "ul" - | "var" - ) -} - -/// Adjust SVG element names from lowercased parser output to proper camelCase. -fn adjust_svg_tag_name(name: &str) -> String { - match name { - "altglyph" => "altGlyph".to_string(), - "altglyphdef" => "altGlyphDef".to_string(), - "altglyphitem" => "altGlyphItem".to_string(), - "animatecolor" => "animateColor".to_string(), - "animatemotion" => "animateMotion".to_string(), - "animatetransform" => "animateTransform".to_string(), - "clippath" => "clipPath".to_string(), - "feblend" => "feBlend".to_string(), - "fecolormatrix" => "feColorMatrix".to_string(), - "fecomponenttransfer" => "feComponentTransfer".to_string(), - "fecomposite" => "feComposite".to_string(), - "feconvolvematrix" => "feConvolveMatrix".to_string(), - "fediffuselighting" => "feDiffuseLighting".to_string(), - "fedisplacementmap" => "feDisplacementMap".to_string(), - "fedistantlight" => "feDistantLight".to_string(), - "fedropshadow" => "feDropShadow".to_string(), - "feflood" => "feFlood".to_string(), - "fefunca" => "feFuncA".to_string(), - "fefuncb" => "feFuncB".to_string(), - "fefuncg" => "feFuncG".to_string(), - "fefuncr" => "feFuncR".to_string(), - "fegaussianblur" => "feGaussianBlur".to_string(), - "feimage" => "feImage".to_string(), - "femerge" => "feMerge".to_string(), - "femergenode" => "feMergeNode".to_string(), - "femorphology" => "feMorphology".to_string(), - "feoffset" => "feOffset".to_string(), - "fepointlight" => "fePointLight".to_string(), - "fespecularlighting" => "feSpecularLighting".to_string(), - "fespotlight" => "feSpotLight".to_string(), - "fetile" => "feTile".to_string(), - "feturbulence" => "feTurbulence".to_string(), - "foreignobject" => "foreignObject".to_string(), - "glyphref" => "glyphRef".to_string(), - "lineargradient" => "linearGradient".to_string(), - "radialgradient" => "radialGradient".to_string(), - "textpath" => "textPath".to_string(), - _ => name.to_string(), - } -} - -/// Adjust SVG attribute names from lowercased to proper camelCase per WHATWG. -fn adjust_svg_attributes(name: &str) -> &str { - match name { - "attributename" => "attributeName", - "attributetype" => "attributeType", - "basefrequency" => "baseFrequency", - "baseprofile" => "baseProfile", - "calcmode" => "calcMode", - "clippathunits" => "clipPathUnits", - "diffuseconstant" => "diffuseConstant", - "edgemode" => "edgeMode", - "filterunits" => "filterUnits", - "glyphref" => "glyphRef", - "gradienttransform" => "gradientTransform", - "gradientunits" => "gradientUnits", - "kernelmatrix" => "kernelMatrix", - "kernelunitlength" => "kernelUnitLength", - "keypoints" => "keyPoints", - "keysplines" => "keySplines", - "keytimes" => "keyTimes", - "lengthadjust" => "lengthAdjust", - "limitingconeangle" => "limitingConeAngle", - "markerheight" => "markerHeight", - "markerunits" => "markerUnits", - "markerwidth" => "markerWidth", - "maskcontentunits" => "maskContentUnits", - "maskunits" => "maskUnits", - "numoctaves" => "numOctaves", - "pathlength" => "pathLength", - "patterncontentunits" => "patternContentUnits", - "patterntransform" => "patternTransform", - "patternunits" => "patternUnits", - "pointsatx" => "pointsAtX", - "pointsaty" => "pointsAtY", - "pointsatz" => "pointsAtZ", - "preservealpha" => "preserveAlpha", - "preserveaspectratio" => "preserveAspectRatio", - "primitiveunits" => "primitiveUnits", - "refx" => "refX", - "refy" => "refY", - "repeatcount" => "repeatCount", - "repeatdur" => "repeatDur", - "requiredextensions" => "requiredExtensions", - "requiredfeatures" => "requiredFeatures", - "specularconstant" => "specularConstant", - "specularexponent" => "specularExponent", - "spreadmethod" => "spreadMethod", - "startoffset" => "startOffset", - "stddeviation" => "stdDeviation", - "stitchtiles" => "stitchTiles", - "surfacescale" => "surfaceScale", - "systemlanguage" => "systemLanguage", - "tablevalues" => "tableValues", - "targetx" => "targetX", - "targety" => "targetY", - "textlength" => "textLength", - "viewbox" => "viewBox", - "viewtarget" => "viewTarget", - "xchannelselector" => "xChannelSelector", - "ychannelselector" => "yChannelSelector", - "zoomandpan" => "zoomAndPan", - _ => name, - } -} - -/// Adjust `MathML` attribute names. -fn adjust_mathml_attributes(name: &str) -> &str { - match name { - "definitionurl" => "definitionURL", - _ => name, - } -} - -/// Parse a foreign attribute name into (prefix, `local_name`, namespace). -fn parse_foreign_attr(name: &str) -> (Option<&str>, &str, Option<&str>) { - match name { - "xlink:actuate" | "xlink:arcrole" | "xlink:href" | "xlink:role" | "xlink:show" - | "xlink:title" | "xlink:type" => { - let local = &name[6..]; // skip "xlink:" - (Some("xlink"), local, Some("http://www.w3.org/1999/xlink")) - } - "xml:lang" | "xml:space" => { - let local = &name[4..]; // skip "xml:" - ( - Some("xml"), - local, - Some("http://www.w3.org/XML/1998/namespace"), - ) - } - "xmlns" => (None, "xmlns", Some("http://www.w3.org/2000/xmlns/")), - "xmlns:xlink" => ( - Some("xmlns"), - "xlink", - Some("http://www.w3.org/2000/xmlns/"), - ), - _ => (None, name, None), - } -} - -// --------------------------------------------------------------------------- -// TreeBuilder -// --------------------------------------------------------------------------- - -/// The HTML5 tree builder state machine. -#[allow(clippy::struct_excessive_bools)] -struct TreeBuilder<'a> { - tokenizer: Tokenizer<'a>, - doc: Document, - mode: InsertionMode, - original_mode: InsertionMode, - open_elements: Vec<StackEntry>, - active_formatting: Vec<FormatEntry>, - head_pointer: Option<NodeId>, - form_pointer: Option<NodeId>, - #[allow(dead_code)] - scripting: bool, - frameset_ok: bool, - foster_parenting: bool, - template_modes: Vec<InsertionMode>, - pending_table_chars: Vec<char>, - /// When true, the next `Character('\n')` token is dropped (leading newline - /// stripping after `<pre>`, `<listing>`, and `<textarea>`). - skip_next_lf: bool, - quirks_mode: QuirksMode, - /// For fragment parsing: the context element name and namespace. - fragment_context: Option<(String, Namespace)>, - #[allow(dead_code)] - errors: Vec<TreeBuildError>, -} - -impl<'a> TreeBuilder<'a> { - fn new(tokenizer: Tokenizer<'a>, options: &Html5ParseOptions) -> Self { - let fragment_context = options.fragment_context.as_ref().map(|ctx| { - // Parse "svg elementname" / "math elementname" / "elementname" - if let Some(name) = ctx.strip_prefix("svg ") { - (name.to_string(), Namespace::Svg) - } else if let Some(name) = ctx.strip_prefix("math ") { - (name.to_string(), Namespace::MathMl) - } else { - (ctx.clone(), Namespace::Html) - } - }); - Self { - tokenizer, - doc: Document::new(), - mode: InsertionMode::Initial, - original_mode: InsertionMode::Initial, - open_elements: Vec::new(), - active_formatting: Vec::new(), - head_pointer: None, - form_pointer: None, - scripting: options.scripting, - frameset_ok: true, - foster_parenting: false, - template_modes: Vec::new(), - pending_table_chars: Vec::new(), - skip_next_lf: false, - quirks_mode: QuirksMode::NoQuirks, - fragment_context, - errors: Vec::new(), - } - } - - // ----------------------------------------------------------------------- - // Main loop - // ----------------------------------------------------------------------- - - /// Initialize the parser for fragment parsing (WHATWG §13.2.1). - fn initialize_fragment(&mut self) { - let Some((ref ctx_name, ctx_ns)) = self.fragment_context.clone() else { - return; - }; - - // Step 5: Create a root html element, append to document, push onto stack. - let html_id = self.doc.create_node(NodeKind::Element { - name: "html".to_string(), - prefix: None, - namespace: None, - attributes: Vec::new(), - }); - self.doc.append_child(self.doc.root(), html_id); - self.open_elements.push(StackEntry { - node_id: html_id, - name: "html".to_string(), - ns: Namespace::Html, - is_html_integration: false, - }); - - // Step 6: If context is template, push InTemplate onto template modes. - if ctx_name == "template" && ctx_ns == Namespace::Html { - self.template_modes.push(InsertionMode::InTemplate); - } - - // Step 8: Set tokenizer state based on context element. - if ctx_ns == Namespace::Html { - match ctx_name.as_str() { - "title" | "textarea" => { - self.tokenizer.set_state(State::RcData); - self.tokenizer.set_last_start_tag(ctx_name); - } - "style" | "xmp" | "iframe" | "noembed" | "noframes" => { - self.tokenizer.set_state(State::RawText); - self.tokenizer.set_last_start_tag(ctx_name); - } - "script" => { - self.tokenizer.set_state(State::ScriptData); - } - "noscript" if self.scripting => { - self.tokenizer.set_state(State::RawText); - self.tokenizer.set_last_start_tag(ctx_name); - } - "plaintext" => { - self.tokenizer.set_state(State::Plaintext); - } - _ => {} - } - } - - // For foreign (SVG/MathML) context elements, push the context - // element onto the stack so that tokens are processed in foreign - // content mode. The element is appended to the html root so that - // child insertion works, but fragment serialization returns its - // children (not the context element itself). - if ctx_ns != Namespace::Html { - let ns_uri = Some(ctx_ns.uri().to_string()); - let ctx_id = self.doc.create_node(NodeKind::Element { - name: ctx_name.clone(), - prefix: None, - namespace: ns_uri, - attributes: Vec::new(), - }); - self.doc.append_child(html_id, ctx_id); - - let is_html_integration = ctx_ns == Namespace::Svg - && matches!(ctx_name.as_str(), "foreignObject" | "desc" | "title"); - self.open_elements.push(StackEntry { - node_id: ctx_id, - name: ctx_name.clone(), - ns: ctx_ns, - is_html_integration, - }); - } - - // Step 10: Reset the insertion mode appropriately. - // (reset_insertion_mode uses fragment_context for the "last" node case) - self.reset_insertion_mode(); - - // Step 12: Set frameset_ok to false. - self.frameset_ok = false; - } - - fn run(&mut self) { - if self.fragment_context.is_some() { - self.initialize_fragment(); - } - loop { - // Inform the tokenizer whether the adjusted current node is in - // a foreign namespace (controls CDATA section handling). - let in_foreign = self - .open_elements - .last() - .is_some_and(|el| el.ns != Namespace::Html); - self.tokenizer.set_allow_cdata(in_foreign); - let token = self.tokenizer.next_token(); - // Leading newline stripping after <pre>, <listing>, <textarea>. - if self.skip_next_lf { - self.skip_next_lf = false; - if token == Token::Character('\n') { - continue; - } - } - - // Fast path: batch consecutive non-null Character tokens in InBody - // mode when there's no foreign content. This avoids per-character - // overhead from process_token dispatch and insertion point lookups. - if self.mode == InsertionMode::InBody && !in_foreign { - if let Token::Character(c) = token { - if c != '\0' { - let mut buf = String::new(); - if !is_ascii_whitespace(c) { - self.frameset_ok = false; - } - buf.push(c); - // Drain consecutive characters from the pending queue. - loop { - let next = self.tokenizer.next_token(); - if let Token::Character(c2) = next { - if c2 == '\0' { - // Null in body: parse error, ignored per spec. - } else { - if !is_ascii_whitespace(c2) { - self.frameset_ok = false; - } - buf.push(c2); - } - } else { - // Non-character token: insert buffered text, then - // process this token normally. - if !buf.is_empty() { - self.reconstruct_formatting(); - self.insert_characters(&buf); - } - if next == Token::Eof { - self.process_token(next); - self.populate_selectedcontent(); - return; - } - // Re-check foreign state before processing next. - let in_foreign_now = self - .open_elements - .last() - .is_some_and(|el| el.ns != Namespace::Html); - self.tokenizer.set_allow_cdata(in_foreign_now); - self.process_token(next); - break; - } - } - continue; - } - } - } - - let is_eof = token == Token::Eof; - self.process_token(token); - if is_eof { - break; - } - } - self.populate_selectedcontent(); - } - - /// Populate `<selectedcontent>` elements inside `<select>` by cloning - /// the content of the selected (or first) `<option>` into them. - fn populate_selectedcontent(&mut self) { - // Collect all selectedcontent elements - let all_nodes: Vec<NodeId> = self.doc.descendants(self.doc.root()).collect(); - let mut selectedcontent_nodes: Vec<NodeId> = Vec::new(); - for &nid in &all_nodes { - if let NodeKind::Element { ref name, .. } = self.doc.node(nid).kind { - if name == "selectedcontent" { - selectedcontent_nodes.push(nid); - } - } - } - - for sc_id in selectedcontent_nodes { - // Walk up to find the containing <select> - let mut select_id = None; - let mut ancestor = self.doc.parent(sc_id); - while let Some(a) = ancestor { - if let NodeKind::Element { ref name, .. } = self.doc.node(a).kind { - if name == "select" { - select_id = Some(a); - break; - } - } - ancestor = self.doc.parent(a); - } - let Some(select_id) = select_id else { - continue; - }; - - // Find the selected option (or first option) inside the select - let option_children: Vec<NodeId> = self.doc.descendants(select_id).collect(); - let mut first_option: Option<NodeId> = None; - let mut selected_option: Option<NodeId> = None; - for &nid in &option_children { - if let NodeKind::Element { - ref name, - ref attributes, - .. - } = self.doc.node(nid).kind - { - if name == "option" { - if first_option.is_none() { - first_option = Some(nid); - } - if attributes.iter().any(|a| a.name == "selected") { - selected_option = Some(nid); - } - } - } - } - - let source = selected_option.or(first_option); - let Some(source) = source else { - continue; - }; - - // Clone all children of the source option into selectedcontent - let children: Vec<NodeId> = self.doc.children(source).collect(); - for child in children { - self.deep_clone_into(child, sc_id); - } - } - } - - /// Deep-clone a node and all its descendants, appending the clone to `parent`. - fn deep_clone_into(&mut self, source: NodeId, parent: NodeId) { - let kind = self.doc.node(source).kind.clone(); - let clone_id = self.doc.create_node(kind); - self.doc.append_child(parent, clone_id); - let children: Vec<NodeId> = self.doc.children(source).collect(); - for child in children { - self.deep_clone_into(child, clone_id); - } - } - - #[allow(clippy::too_many_lines)] - fn process_token(&mut self, token: Token) { - // Determine whether to use normal insertion mode rules or foreign content. - // Per WHATWG §13.2.6. - if self.should_use_foreign_content_rules(&token) { - self.handle_foreign_content(token); - return; - } - - match self.mode { - InsertionMode::Initial => self.handle_initial(token), - InsertionMode::BeforeHtml => self.handle_before_html(token), - InsertionMode::BeforeHead => self.handle_before_head(token), - InsertionMode::InHead => self.handle_in_head(token), - InsertionMode::InHeadNoscript => self.handle_in_head_noscript(token), - InsertionMode::AfterHead => self.handle_after_head(token), - InsertionMode::InBody => self.handle_in_body(token), - InsertionMode::Text => self.handle_text(token), - InsertionMode::InTable => self.handle_in_table(token), - InsertionMode::InTableText => self.handle_in_table_text(token), - InsertionMode::InCaption => self.handle_in_caption(token), - InsertionMode::InColumnGroup => self.handle_in_column_group(token), - InsertionMode::InTableBody => self.handle_in_table_body(token), - InsertionMode::InRow => self.handle_in_row(token), - InsertionMode::InCell => self.handle_in_cell(token), - InsertionMode::InSelect => self.handle_in_select(token), - InsertionMode::InSelectInTable => self.handle_in_select_in_table(token), - InsertionMode::InTemplate => self.handle_in_template(token), - InsertionMode::AfterBody => self.handle_after_body(token), - InsertionMode::InFrameset => self.handle_in_frameset(token), - InsertionMode::AfterFrameset => self.handle_after_frameset(token), - InsertionMode::AfterAfterBody => self.handle_after_after_body(token), - InsertionMode::AfterAfterFrameset => self.handle_after_after_frameset(token), - } - } - - // ----------------------------------------------------------------------- - // Foreign content dispatcher (WHATWG §13.2.6) - // ----------------------------------------------------------------------- - - fn is_mathml_text_integration_point(entry: &StackEntry) -> bool { - entry.ns == Namespace::MathMl - && matches!(entry.name.as_str(), "mi" | "mo" | "mn" | "ms" | "mtext") - } - - fn is_html_integration_point(entry: &StackEntry) -> bool { - if entry.ns == Namespace::Svg - && matches!(entry.name.as_str(), "foreignObject" | "desc" | "title") - { - return true; - } - // MathML annotation-xml with encoding text/html or application/xhtml+xml - entry.is_html_integration - } - - fn should_use_foreign_content_rules(&self, token: &Token) -> bool { - let Some(cur) = self.open_elements.last() else { - return false; - }; - - // If adjusted current node is in HTML namespace, use normal rules. - if cur.ns == Namespace::Html { - return false; - } - - // MathML text integration point: start tags (except mglyph/malignmark) - // and character tokens use normal rules. - if Self::is_mathml_text_integration_point(cur) { - match token { - Token::StartTag { name, .. } if name != "mglyph" && name != "malignmark" => { - return false; - } - Token::Character(_) => return false, - _ => {} - } - } - - // MathML annotation-xml + start tag "svg" → normal rules - if cur.ns == Namespace::MathMl && cur.name == "annotation-xml" { - if let Token::StartTag { name, .. } = token { - if name == "svg" { - return false; - } - } - } - - // HTML integration point: start tags and character tokens use normal rules. - if Self::is_html_integration_point(cur) { - match token { - Token::StartTag { .. } | Token::Character(_) => return false, - _ => {} - } - } - - // EOF always uses normal rules. - if *token == Token::Eof { - return false; - } - - true - } - - #[allow(clippy::too_many_lines)] - fn handle_foreign_content(&mut self, token: Token) { - match token { - Token::Character('\0') => { - self.insert_character('\u{FFFD}'); - } - Token::Character(c) if is_ascii_whitespace(c) => { - self.insert_character(c); - } - Token::Character(c) => { - self.insert_character(c); - self.frameset_ok = false; - } - Token::Comment(data) => { - self.insert_comment(&data); - } - Token::Doctype { .. } | Token::Eof => { - // Parse error, ignore. - } - Token::StartTag { - ref name, - ref attributes, - .. - } if is_foreign_breakout_tag(name) - || (name == "font" - && attributes - .iter() - .any(|a| matches!(a.name.as_str(), "color" | "face" | "size"))) => - { - // Parse error. Pop until MathML text integration point, - // HTML integration point, or HTML namespace element. - while let Some(top) = self.open_elements.last() { - if top.ns == Namespace::Html - || Self::is_mathml_text_integration_point(top) - || Self::is_html_integration_point(top) - { - break; - } - // In fragment mode, don't pop the context element. - if self.open_elements.len() <= 2 - && self - .fragment_context - .as_ref() - .is_some_and(|(_, ns)| *ns != Namespace::Html) - { - break; - } - self.open_elements.pop(); - } - // Use dispatch_to_current_mode to avoid re-entering - // foreign content handling when the context is foreign. - self.dispatch_to_current_mode(token); - } - Token::StartTag { - name, - attributes, - self_closing, - } => { - // Any other start tag in foreign content. - let cur_ns = self.open_elements.last().map_or(Namespace::Html, |e| e.ns); - - let (adjusted_name, adjusted_attrs, ns) = match cur_ns { - Namespace::MathMl => { - let attrs: Vec<tokenizer::Attribute> = attributes - .iter() - .map(|a| tokenizer::Attribute { - name: adjust_mathml_attributes(&a.name).to_string(), - value: a.value.clone(), - }) - .collect(); - (name, attrs, Namespace::MathMl) - } - Namespace::Svg => { - let tag = adjust_svg_tag_name(&name); - let attrs: Vec<tokenizer::Attribute> = attributes - .iter() - .map(|a| tokenizer::Attribute { - name: adjust_svg_attributes(&a.name).to_string(), - value: a.value.clone(), - }) - .collect(); - (tag, attrs, Namespace::Svg) - } - Namespace::Html => (name, attributes, Namespace::Html), - }; - - self.insert_foreign_element(&adjusted_name, &adjusted_attrs, ns); - - if self_closing { - self.open_elements.pop(); - } - } - Token::EndTag { ref name } if name == "br" || name == "p" => { - // Per spec §13.2.6.5: parse error. Pop until we reach an - // HTML namespace element, MathML text integration point, or - // HTML integration point; then reprocess as "in body". - // In fragment mode, never pop below the context element. - let min_stack = if self - .fragment_context - .as_ref() - .is_some_and(|(_, ns)| *ns != Namespace::Html) - { - 2 - } else { - 1 - }; - while self.open_elements.len() > min_stack { - let Some(top) = self.open_elements.last() else { - break; - }; - if top.ns == Namespace::Html - || Self::is_mathml_text_integration_point(top) - || Self::is_html_integration_point(top) - { - break; - } - self.open_elements.pop(); - } - self.dispatch_to_current_mode(token); - } - Token::EndTag { name } => { - // Any other end tag in foreign content. - self.handle_foreign_end_tag(&name); - } - } - } - - fn handle_foreign_end_tag(&mut self, tag_name: &str) { - if self.open_elements.is_empty() { - return; - } - - // In fragment parsing, never pop below the initial stack - // (html element, or html + context element for foreign contexts). - let min_idx = if self.fragment_context.is_some() { - // html is at 0; for foreign contexts the context element is at 1. - if self - .fragment_context - .as_ref() - .is_some_and(|(_, ns)| *ns != Namespace::Html) - { - 2 - } else { - 1 - } - } else { - 0 - }; - - let mut node_idx = self.open_elements.len() - 1; - - loop { - let node = &self.open_elements[node_idx]; - - if node.ns == Namespace::Html { - // Process using the rules for the current insertion mode - // (not process_token, to avoid re-entering foreign content). - self.dispatch_to_current_mode(Token::EndTag { - name: tag_name.to_string(), - }); - return; - } - - if node.name.eq_ignore_ascii_case(tag_name) { - // Don't pop the context element or below it. - let pop_to = node_idx.max(min_idx); - while self.open_elements.len() > pop_to { - self.open_elements.pop(); - } - return; - } - - if node_idx <= min_idx { - return; - } - node_idx -= 1; - } - } - - /// Dispatch a token directly to the current insertion mode handler, - /// bypassing the foreign content check. - fn dispatch_to_current_mode(&mut self, token: Token) { - match self.mode { - InsertionMode::Initial => self.handle_initial(token), - InsertionMode::BeforeHtml => self.handle_before_html(token), - InsertionMode::BeforeHead => self.handle_before_head(token), - InsertionMode::InHead => self.handle_in_head(token), - InsertionMode::InHeadNoscript => self.handle_in_head_noscript(token), - InsertionMode::AfterHead => self.handle_after_head(token), - InsertionMode::InBody => self.handle_in_body(token), - InsertionMode::Text => self.handle_text(token), - InsertionMode::InTable => self.handle_in_table(token), - InsertionMode::InTableText => self.handle_in_table_text(token), - InsertionMode::InCaption => self.handle_in_caption(token), - InsertionMode::InColumnGroup => self.handle_in_column_group(token), - InsertionMode::InTableBody => self.handle_in_table_body(token), - InsertionMode::InRow => self.handle_in_row(token), - InsertionMode::InCell => self.handle_in_cell(token), - InsertionMode::InSelect => self.handle_in_select(token), - InsertionMode::InSelectInTable => self.handle_in_select_in_table(token), - InsertionMode::InTemplate => self.handle_in_template(token), - InsertionMode::AfterBody => self.handle_after_body(token), - InsertionMode::InFrameset => self.handle_in_frameset(token), - InsertionMode::AfterFrameset => self.handle_after_frameset(token), - InsertionMode::AfterAfterBody => self.handle_after_after_body(token), - InsertionMode::AfterAfterFrameset => self.handle_after_after_frameset(token), - } - } - - fn insert_foreign_element( - &mut self, - name: &str, - attrs: &[tokenizer::Attribute], - ns: Namespace, - ) -> NodeId { - let tree_attrs: Vec<crate::tree::Attribute> = attrs - .iter() - .map(|a| { - let (prefix, local_name, attr_ns) = parse_foreign_attr(&a.name); - crate::tree::Attribute { - name: local_name.to_string(), - value: a.value.clone(), - prefix: prefix.map(String::from), - namespace: attr_ns.map(String::from), - raw_value: None, - } - }) - .collect(); - - let namespace = if ns == Namespace::Html { - None - } else { - Some(ns.uri().to_string()) - }; - - let id_value = tree_attrs.iter().find_map(|a| { - if a.name == "id" { - Some(a.value.clone()) - } else { - None - } - }); - - let node_id = self.doc.create_node(NodeKind::Element { - name: name.to_string(), - prefix: None, - namespace, - attributes: tree_attrs, - }); - - if let Some(id_val) = id_value { - self.doc.set_id(&id_val, node_id); - } - - // Detect HTML integration point: MathML annotation-xml with - // encoding="text/html" or "application/xhtml+xml". - let is_html_integration = ns == Namespace::MathMl - && name == "annotation-xml" - && attrs.iter().any(|a| { - a.name.eq_ignore_ascii_case("encoding") - && (a.value.eq_ignore_ascii_case("text/html") - || a.value.eq_ignore_ascii_case("application/xhtml+xml")) - }); - - let (parent, before) = self.appropriate_insertion_point(); - self.insert_node_at(node_id, parent, before); - self.open_elements.push(StackEntry { - node_id, - name: name.to_string(), - ns, - is_html_integration, - }); - node_id - } - - // ----------------------------------------------------------------------- - // Stack / scope helpers - // ----------------------------------------------------------------------- - - fn current_node(&self) -> Option<NodeId> { - self.open_elements.last().map(|e| e.node_id) - } - - fn current_node_name(&self) -> &str { - self.open_elements.last().map_or("", |e| e.name.as_str()) - } - - fn element_in_scope_impl(&self, target: &str, scope_fn: fn(&str, Namespace) -> bool) -> bool { - for entry in self.open_elements.iter().rev() { - if entry.name == target && entry.ns == Namespace::Html { - return true; - } - if scope_fn(&entry.name, entry.ns) { - return false; - } - } - false - } - - fn element_in_scope(&self, target: &str) -> bool { - self.element_in_scope_impl(target, is_scope_element) - } - - fn element_in_list_item_scope(&self, target: &str) -> bool { - self.element_in_scope_impl(target, is_list_item_scope_element) - } - - fn element_in_button_scope(&self, target: &str) -> bool { - self.element_in_scope_impl(target, is_button_scope_element) - } - - fn element_in_table_scope(&self, target: &str) -> bool { - self.element_in_scope_impl(target, is_table_scope_element) - } - - fn element_in_select_scope(&self, target: &str) -> bool { - self.element_in_scope_impl(target, is_select_scope_element) - } - - // ----------------------------------------------------------------------- - // Insertion helpers - // ----------------------------------------------------------------------- - - fn appropriate_insertion_point(&self) -> (NodeId, Option<NodeId>) { - self.appropriate_insertion_point_with_override(None) - } - - fn appropriate_insertion_point_with_override( - &self, - override_target: Option<NodeId>, - ) -> (NodeId, Option<NodeId>) { - let target = override_target - .unwrap_or_else(|| self.current_node().unwrap_or_else(|| self.doc.root())); - - // Per WHATWG spec §13.2.6.1: foster parenting only applies when the - // target is a table, tbody, tfoot, thead, or tr element. - if self.foster_parenting { - let target_name = self - .open_elements - .iter() - .find(|e| e.node_id == target) - .map_or("", |e| e.name.as_str()); - if matches!(target_name, "table" | "tbody" | "tfoot" | "thead" | "tr") { - // Find the last table and last template in the stack. - let mut last_table: Option<usize> = None; - let mut last_template: Option<usize> = None; - for i in (0..self.open_elements.len()).rev() { - if self.open_elements[i].name == "table" - && self.open_elements[i].ns == Namespace::Html - && last_table.is_none() - { - last_table = Some(i); - } - if self.open_elements[i].name == "template" - && self.open_elements[i].ns == Namespace::Html - && last_template.is_none() - { - last_template = Some(i); - } - } - - // If template comes after table (or no table), insert - // inside the template element (its content). - if let Some(tmpl_idx) = last_template { - if last_table.is_none() || tmpl_idx > last_table.unwrap_or(0) { - return (self.open_elements[tmpl_idx].node_id, None); - } - } - - if let Some(table_idx) = last_table { - if let Some(parent) = self.doc.parent(self.open_elements[table_idx].node_id) { - return (parent, Some(self.open_elements[table_idx].node_id)); - } - // If table has no parent, use the element before it in the stack - if table_idx > 0 { - return (self.open_elements[table_idx - 1].node_id, None); - } - } - - // No table or template — fall back to first element - if !self.open_elements.is_empty() { - return (self.open_elements[0].node_id, None); - } - } - } - (target, None) - } - - fn insert_node_at(&mut self, node_id: NodeId, parent: NodeId, before: Option<NodeId>) { - if let Some(ref_node) = before { - self.doc.insert_before(ref_node, node_id); - } else { - self.doc.append_child(parent, node_id); - } - } - - fn create_element_for_token( - &mut self, - name: &str, - attrs: &[tokenizer::Attribute], - ns: Namespace, - ) -> NodeId { - let tree_attrs: Vec<crate::tree::Attribute> = attrs - .iter() - .map(|a| crate::tree::Attribute { - name: a.name.clone(), - value: a.value.clone(), - prefix: None, - namespace: None, - raw_value: None, - }) - .collect(); - - let namespace = if ns == Namespace::Html { - None - } else { - Some(ns.uri().to_string()) - }; - - self.doc.create_node(NodeKind::Element { - name: name.to_string(), - prefix: None, - namespace, - attributes: tree_attrs, - }) - } - - fn insert_element( - &mut self, - name: &str, - attrs: &[tokenizer::Attribute], - ns: Namespace, - ) -> NodeId { - let node_id = self.create_element_for_token(name, attrs, ns); - let (parent, before) = self.appropriate_insertion_point(); - self.insert_node_at(node_id, parent, before); - self.open_elements.push(StackEntry { - node_id, - name: name.to_string(), - ns, - is_html_integration: false, - }); - node_id - } - - fn insert_html_element(&mut self, name: &str, attrs: &[tokenizer::Attribute]) -> NodeId { - self.insert_element(name, attrs, Namespace::Html) - } - - fn insert_character(&mut self, c: char) { - let (parent, before) = self.appropriate_insertion_point(); - - // Try to append to existing text node — either the last child of - // parent (normal case) or the previous sibling of the reference node - // (foster-parenting case). - let adjacent_text = if let Some(ref_node) = before { - self.doc.prev_sibling(ref_node) - } else { - self.doc.last_child(parent) - }; - if let Some(text_node) = adjacent_text { - if let NodeKind::Text { ref mut content } = &mut self.doc.node_mut(text_node).kind { - content.push(c); - return; - } - } - - let text_id = self.doc.create_node(NodeKind::Text { - content: c.to_string(), - }); - self.insert_node_at(text_id, parent, before); - } - - /// Append a string of characters to the current insertion point. - /// - /// This is an optimization over calling `insert_character` per-char: - /// it computes the insertion point once and appends the whole string. - fn insert_characters(&mut self, s: &str) { - let (parent, before) = self.appropriate_insertion_point(); - let adjacent_text = if let Some(ref_node) = before { - self.doc.prev_sibling(ref_node) - } else { - self.doc.last_child(parent) - }; - if let Some(text_node) = adjacent_text { - if let NodeKind::Text { ref mut content } = &mut self.doc.node_mut(text_node).kind { - content.push_str(s); - return; - } - } - let text_id = self.doc.create_node(NodeKind::Text { - content: s.to_string(), - }); - self.insert_node_at(text_id, parent, before); - } - - fn insert_comment(&mut self, data: &str) { - let (parent, before) = self.appropriate_insertion_point(); - let comment_id = self.doc.create_node(NodeKind::Comment { - content: data.to_string(), - }); - self.insert_node_at(comment_id, parent, before); - } - - fn insert_comment_at_document(&mut self, data: &str) { - let doc_root = self.doc.root(); - let comment_id = self.doc.create_node(NodeKind::Comment { - content: data.to_string(), - }); - self.doc.append_child(doc_root, comment_id); - } - - // ----------------------------------------------------------------------- - // Implied end tags - // ----------------------------------------------------------------------- - - fn generate_implied_end_tags(&mut self, exclude: Option<&str>) { - loop { - let name = self.current_node_name().to_string(); - if matches!( - name.as_str(), - "dd" | "dt" | "li" | "optgroup" | "option" | "p" | "rb" | "rp" | "rt" | "rtc" - ) && exclude.map_or(true, |ex| ex != name) - { - self.open_elements.pop(); - } else { - break; - } - } - } - - fn generate_all_implied_end_tags(&mut self) { - loop { - let name = self.current_node_name().to_string(); - if matches!( - name.as_str(), - "dd" | "dt" - | "li" - | "optgroup" - | "option" - | "p" - | "rb" - | "rp" - | "rt" - | "rtc" - | "tbody" - | "td" - | "tfoot" - | "th" - | "thead" - | "tr" - | "caption" - | "colgroup" - ) { - self.open_elements.pop(); - } else { - break; - } - } - } - - fn close_p_element(&mut self) { - self.generate_implied_end_tags(Some("p")); - // Pop until p - while let Some(entry) = self.open_elements.pop() { - if entry.name == "p" { - break; - } - } - } - - // ----------------------------------------------------------------------- - // Active formatting list - // ----------------------------------------------------------------------- - - fn push_formatting(&mut self, node_id: NodeId, name: &str, attrs: &[tokenizer::Attribute]) { - // Noah's Ark clause: if there are already 3 entries with the same - // tag name and attributes before the last marker, remove the earliest. - let mut count = 0; - let mut earliest_idx = None; - for (i, entry) in self.active_formatting.iter().enumerate().rev() { - match entry { - FormatEntry::Marker => break, - FormatEntry::Element { - name: n, attrs: a, .. - } if n == name && a == attrs => { - count += 1; - earliest_idx = Some(i); - } - FormatEntry::Element { .. } => {} - } - } - if count >= 3 { - if let Some(idx) = earliest_idx { - self.active_formatting.remove(idx); - } - } - self.active_formatting.push(FormatEntry::Element { - node_id, - name: name.to_string(), - attrs: attrs.to_vec(), - }); - } - - fn push_formatting_marker(&mut self) { - self.active_formatting.push(FormatEntry::Marker); - } - - fn clear_formatting_to_marker(&mut self) { - while let Some(entry) = self.active_formatting.pop() { - if matches!(entry, FormatEntry::Marker) { - break; - } - } - } - - fn reconstruct_formatting(&mut self) { - if self.active_formatting.is_empty() { - return; - } - - // If the last entry is a marker or already on the stack, nothing to do. - if let Some(last) = self.active_formatting.last() { - match last { - FormatEntry::Marker => return, - FormatEntry::Element { node_id, .. } => { - if self.open_elements.iter().any(|e| e.node_id == *node_id) { - return; - } - } - } - } - - // Walk backwards to find the first entry that IS on the stack or is a marker. - let mut i = self.active_formatting.len() - 1; - loop { - if i == 0 { - break; - } - i -= 1; - match &self.active_formatting[i] { - FormatEntry::Marker => { - i += 1; - break; - } - FormatEntry::Element { node_id, .. } => { - if self.open_elements.iter().any(|e| e.node_id == *node_id) { - i += 1; - break; - } - } - } - } - - // Now walk forward from i, creating new elements. - while i < self.active_formatting.len() { - let (name, attrs) = match &self.active_formatting[i] { - FormatEntry::Element { name, attrs, .. } => (name.clone(), attrs.clone()), - FormatEntry::Marker => { - i += 1; - continue; - } - }; - - let new_id = self.insert_html_element(&name, &attrs); - self.active_formatting[i] = FormatEntry::Element { - node_id: new_id, - name, - attrs, - }; - i += 1; - } - } - - // ----------------------------------------------------------------------- - // Adoption agency algorithm (WHATWG 13.2.6.4.7) - // ----------------------------------------------------------------------- - - #[allow(clippy::too_many_lines)] - fn adoption_agency(&mut self, tag_name: &str) { - // Step 1: If current node is an HTML element with tag name equal to - // the token's tag name, and the current node is not in the active - // formatting list, just pop it. - if let Some(cur) = self.open_elements.last() { - if cur.name == tag_name && cur.ns == Namespace::Html { - let cur_id = cur.node_id; - let in_formatting = self.active_formatting.iter().any( - |e| matches!(e, FormatEntry::Element { node_id, .. } if *node_id == cur_id), - ); - if !in_formatting { - self.open_elements.pop(); - return; - } - } - } - - // Outer loop (max 8 iterations) - for _ in 0..8 { - // Step 4: Find the formatting element — the last entry in the - // active formatting list that has tag name equal to tag_name and - // that is before the last marker (or the start of the list). - let fmt_idx = { - let mut found = None; - for (i, entry) in self.active_formatting.iter().enumerate().rev() { - match entry { - FormatEntry::Marker => break, - FormatEntry::Element { name, .. } if name == tag_name => { - found = Some(i); - break; - } - FormatEntry::Element { .. } => {} - } - } - found - }; - - let Some(fmt_idx) = fmt_idx else { - // No formatting element found; process as "any other end tag". - self.handle_any_other_end_tag(tag_name); - return; - }; - - let FormatEntry::Element { - node_id: fmt_node_id, - name: ref fmt_name, - attrs: ref fmt_attrs, - } = self.active_formatting[fmt_idx] - else { - return; - }; - let fmt_name = fmt_name.clone(); - let fmt_attrs = fmt_attrs.clone(); - - // Step 5: If the formatting element is not on the stack of open - // elements, remove it from the formatting list and return. - let Some(stack_idx) = self - .open_elements - .iter() - .position(|e| e.node_id == fmt_node_id) - else { - self.active_formatting.remove(fmt_idx); - return; - }; - - // Step 6: If the formatting element is not in scope, return. - if !self.element_in_scope(&fmt_name) { - return; - } - - // Step 8: Find the furthest block. - let furthest_block_idx = self.open_elements[stack_idx + 1..] - .iter() - .position(|e| is_special_element_ns(&e.name, e.ns)) - .map(|i| i + stack_idx + 1); - - // Step 9: No furthest block → pop to formatting element. - let Some(furthest_block_idx) = furthest_block_idx else { - while self.open_elements.len() > stack_idx { - self.open_elements.pop(); - } - self.active_formatting.remove(fmt_idx); - return; - }; - - // We need to track the furthest block by node_id since indices shift. - let furthest_block_node_id = self.open_elements[furthest_block_idx].node_id; - - // Step 10-11 - let common_ancestor = self.open_elements[stack_idx - 1].node_id; - let mut bookmark = fmt_idx; - - // Step 12: inner loop - let mut node_stack_idx = furthest_block_idx; - let mut last_node_id = furthest_block_node_id; - let mut inner_counter = 0u32; - - loop { - inner_counter += 1; - - // Step 12.2: node = element immediately above node in stack - if node_stack_idx == 0 { - break; - } - node_stack_idx -= 1; - if node_stack_idx <= stack_idx { - break; - } - - let node_id = self.open_elements[node_stack_idx].node_id; - - // Step 12.3: Check if node is in the active formatting list - let fmt_list_idx = self.active_formatting.iter().position( - |e| matches!(e, FormatEntry::Element { node_id: nid, .. } if *nid == node_id), - ); - - // Step 12.4: If not in formatting list, remove from stack - let Some(fmt_list_idx) = fmt_list_idx else { - self.open_elements.remove(node_stack_idx); - continue; - }; - - // Step 12.5: If inner counter > 3, remove from formatting list - if inner_counter > 3 { - self.active_formatting.remove(fmt_list_idx); - if bookmark > fmt_list_idx { - bookmark -= 1; - } - self.open_elements.remove(node_stack_idx); - continue; - } - - // Step 12.6-7: Create replacement element - let (old_name, old_attrs) = match &self.active_formatting[fmt_list_idx] { - FormatEntry::Element { name, attrs, .. } => (name.clone(), attrs.clone()), - FormatEntry::Marker => continue, - }; - - let new_element = - self.create_element_for_token(&old_name, &old_attrs, Namespace::Html); - - self.active_formatting[fmt_list_idx] = FormatEntry::Element { - node_id: new_element, - name: old_name.clone(), - attrs: old_attrs, - }; - self.open_elements[node_stack_idx] = StackEntry { - node_id: new_element, - name: old_name, - ns: Namespace::Html, - is_html_integration: false, - }; - - // Step 12.8: If last node was the furthest block, move bookmark - if last_node_id == furthest_block_node_id { - bookmark = fmt_list_idx + 1; - } - - // Step 12.9: Move last_node to be a child of new_element - self.doc.detach(last_node_id); - self.doc.append_child(new_element, last_node_id); - last_node_id = new_element; - } - - // Step 13: insert last_node at the appropriate place - self.doc.detach(last_node_id); - // Use the appropriate insertion point with common ancestor as the - // override target. This correctly handles foster parenting when - // the common ancestor is a table-related element. - let (parent, before) = - self.appropriate_insertion_point_with_override(Some(common_ancestor)); - self.insert_node_at(last_node_id, parent, before); - - // Step 14: create a new element for the formatting element - let new_fmt = self.create_element_for_token(&fmt_name, &fmt_attrs, Namespace::Html); - - // Step 15: move children of the furthest block to the new element - let fb_id = self - .open_elements - .iter() - .find(|e| e.node_id == furthest_block_node_id) - .map(|e| e.node_id); - if let Some(fb_id) = fb_id { - let children: Vec<NodeId> = self.doc.children(fb_id).collect(); - for child in children { - self.doc.detach(child); - self.doc.append_child(new_fmt, child); - } - // Step 16: append new element to the furthest block - self.doc.append_child(fb_id, new_fmt); - } - - // Step 17: remove old formatting element, insert new at bookmark - if let Some(old_pos) = self.active_formatting.iter().position( - |e| matches!(e, FormatEntry::Element { node_id, .. } if *node_id == fmt_node_id), - ) { - self.active_formatting.remove(old_pos); - if bookmark > old_pos { - bookmark -= 1; - } - } - let bookmark = bookmark.min(self.active_formatting.len()); - self.active_formatting.insert( - bookmark, - FormatEntry::Element { - node_id: new_fmt, - name: fmt_name.clone(), - attrs: fmt_attrs.clone(), - }, - ); - - // Step 18: remove old from stack, insert new after furthest block - if let Some(old_pos) = self - .open_elements - .iter() - .position(|e| e.node_id == fmt_node_id) - { - self.open_elements.remove(old_pos); - } - if let Some(fb_pos) = - fb_id.and_then(|fb| self.open_elements.iter().position(|e| e.node_id == fb)) - { - let insert_pos = (fb_pos + 1).min(self.open_elements.len()); - self.open_elements.insert( - insert_pos, - StackEntry { - node_id: new_fmt, - name: fmt_name, - ns: Namespace::Html, - is_html_integration: false, - }, - ); - } - } - } - - // ----------------------------------------------------------------------- - // Tokenizer state switching for raw text elements - // ----------------------------------------------------------------------- - - #[allow(dead_code)] - fn switch_tokenizer_for_raw(&mut self, name: &str) { - match name { - "script" => self.tokenizer.set_state(State::ScriptData), - "style" | "noframes" | "noembed" | "noscript" => { - self.tokenizer.set_state(State::RawText); - } - "textarea" | "title" => { - self.tokenizer.set_state(State::RcData); - } - "plaintext" => self.tokenizer.set_state(State::Plaintext), - _ => {} - } - self.tokenizer.set_last_start_tag(name); - } - - fn parse_raw_text(&mut self, name: &str, attrs: &[tokenizer::Attribute]) { - self.insert_html_element(name, attrs); - self.tokenizer.set_state(State::RawText); - self.tokenizer.set_last_start_tag(name); - self.original_mode = self.mode; - self.mode = InsertionMode::Text; - } - - fn parse_rcdata(&mut self, name: &str, attrs: &[tokenizer::Attribute]) { - self.insert_html_element(name, attrs); - self.tokenizer.set_state(State::RcData); - self.tokenizer.set_last_start_tag(name); - self.original_mode = self.mode; - self.mode = InsertionMode::Text; - } - - // ----------------------------------------------------------------------- - // Insertion mode handlers - // ----------------------------------------------------------------------- - - fn handle_initial(&mut self, token: Token) { - match token { - Token::Character(c) if is_ascii_whitespace(c) => { - // Ignore - } - Token::Comment(data) => { - self.insert_comment_at_document(&data); - } - Token::Doctype { - name, - public_id, - system_id, - force_quirks, - } => { - let doctype_name = name.unwrap_or_default(); - self.quirks_mode = determine_quirks_mode( - &doctype_name, - public_id.as_deref(), - system_id.as_deref(), - force_quirks, - ); - let doctype_id = self.doc.create_node(NodeKind::DocumentType { - name: doctype_name, - public_id, - system_id, - internal_subset: None, - }); - let root = self.doc.root(); - self.doc.append_child(root, doctype_id); - self.mode = InsertionMode::BeforeHtml; - } - _ => { - // Missing DOCTYPE → quirks mode. - self.quirks_mode = QuirksMode::Quirks; - self.mode = InsertionMode::BeforeHtml; - self.process_token(token); - } - } - } - - fn handle_before_html(&mut self, token: Token) { - match token { - Token::Comment(data) => { - self.insert_comment_at_document(&data); - } - Token::Doctype { .. } => { /* ignore */ } - Token::Character(c) if is_ascii_whitespace(c) => { /* ignore */ } - Token::StartTag { ref name, .. } if name == "html" => { - if let Token::StartTag { - name, attributes, .. - } = token - { - let node_id = - self.create_element_for_token(&name, &attributes, Namespace::Html); - let root = self.doc.root(); - self.doc.append_child(root, node_id); - self.open_elements.push(StackEntry { - node_id, - name, - ns: Namespace::Html, - is_html_integration: false, - }); - self.mode = InsertionMode::BeforeHead; - } - } - Token::EndTag { ref name } - if !matches!(name.as_str(), "head" | "body" | "html" | "br") => - { - // Parse error, ignore - } - _ => { - let node_id = self.create_element_for_token("html", &[], Namespace::Html); - let root = self.doc.root(); - self.doc.append_child(root, node_id); - self.open_elements.push(StackEntry { - node_id, - name: "html".to_string(), - ns: Namespace::Html, - is_html_integration: false, - }); - self.mode = InsertionMode::BeforeHead; - self.process_token(token); - } - } - } - - fn handle_before_head(&mut self, token: Token) { - match token { - Token::Character(c) if is_ascii_whitespace(c) => { /* ignore */ } - Token::Comment(data) => self.insert_comment(&data), - Token::Doctype { .. } => { /* ignore */ } - Token::StartTag { ref name, .. } if name == "html" => { - self.handle_in_body(token); - } - Token::StartTag { ref name, .. } if name == "head" => { - if let Token::StartTag { - name, attributes, .. - } = token - { - let node_id = self.insert_html_element(&name, &attributes); - self.head_pointer = Some(node_id); - self.mode = InsertionMode::InHead; - } - } - Token::EndTag { ref name } - if !matches!(name.as_str(), "head" | "body" | "html" | "br") => - { - // Parse error, ignore - } - _ => { - let node_id = self.insert_html_element("head", &[]); - self.head_pointer = Some(node_id); - self.mode = InsertionMode::InHead; - self.process_token(token); - } - } - } - - #[allow(clippy::too_many_lines, clippy::match_same_arms)] - fn handle_in_head(&mut self, token: Token) { - match token { - Token::Character(c) if is_ascii_whitespace(c) => { - self.insert_character(c); - } - Token::Comment(data) => self.insert_comment(&data), - Token::Doctype { .. } => { /* ignore */ } - Token::StartTag { ref name, .. } if name == "html" => { - self.handle_in_body(token); - } - Token::StartTag { ref name, .. } - if matches!( - name.as_str(), - "base" | "basefont" | "bgsound" | "link" | "meta" - ) => - { - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.open_elements.pop(); // void - } - } - Token::StartTag { ref name, .. } if name == "title" => { - if let Token::StartTag { - name, attributes, .. - } = token - { - self.parse_rcdata(&name, &attributes); - } - } - Token::StartTag { ref name, .. } if name == "noscript" && self.scripting => { - if let Token::StartTag { - name, attributes, .. - } = token - { - self.parse_raw_text(&name, &attributes); - } - } - Token::StartTag { ref name, .. } if matches!(name.as_str(), "noframes" | "style") => { - if let Token::StartTag { - name, attributes, .. - } = token - { - self.parse_raw_text(&name, &attributes); - } - } - Token::StartTag { ref name, .. } if name == "noscript" => { - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.mode = InsertionMode::InHeadNoscript; - } - } - Token::StartTag { ref name, .. } if name == "script" => { - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.tokenizer.set_state(State::ScriptData); - self.tokenizer.set_last_start_tag(&name); - self.original_mode = self.mode; - self.mode = InsertionMode::Text; - } - } - Token::EndTag { ref name } if name == "head" => { - self.open_elements.pop(); - self.mode = InsertionMode::AfterHead; - } - Token::EndTag { ref name } if matches!(name.as_str(), "body" | "html" | "br") => { - self.open_elements.pop(); - self.mode = InsertionMode::AfterHead; - self.process_token(token); - } - Token::StartTag { ref name, .. } if name == "template" => { - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.push_formatting_marker(); - self.frameset_ok = false; - self.mode = InsertionMode::InTemplate; - self.template_modes.push(InsertionMode::InTemplate); - } - } - Token::EndTag { ref name } if name == "template" => { - if self - .open_elements - .iter() - .any(|e| e.name == "template" && e.ns == Namespace::Html) - { - self.generate_all_implied_end_tags(); - while let Some(entry) = self.open_elements.pop() { - if entry.name == "template" && entry.ns == Namespace::Html { - break; - } - } - self.clear_formatting_to_marker(); - self.template_modes.pop(); - self.reset_insertion_mode(); - } - } - Token::StartTag { ref name, .. } if name == "head" => { - // Parse error, ignore - } - Token::EndTag { .. } => { - // Parse error, ignore - } - _ => { - self.open_elements.pop(); - self.mode = InsertionMode::AfterHead; - self.process_token(token); - } - } - } - - fn handle_in_head_noscript(&mut self, token: Token) { - match token { - Token::Doctype { .. } => { /* ignore */ } - Token::StartTag { ref name, .. } if name == "html" => { - self.handle_in_body(token); - } - Token::EndTag { ref name } if name == "noscript" => { - self.open_elements.pop(); - self.mode = InsertionMode::InHead; - } - Token::Character(c) if is_ascii_whitespace(c) => { - self.handle_in_head(token); - } - Token::Comment(_) => { - self.handle_in_head(token); - } - Token::StartTag { ref name, .. } - if matches!( - name.as_str(), - "basefont" | "bgsound" | "link" | "meta" | "noframes" | "style" - ) => - { - self.handle_in_head(token); - } - Token::StartTag { ref name, .. } if matches!(name.as_str(), "head" | "noscript") => { - // Parse error, ignore - } - Token::EndTag { ref name } if name != "br" => { - // Parse error, ignore - } - _ => { - self.open_elements.pop(); - self.mode = InsertionMode::InHead; - self.process_token(token); - } - } - } - - fn handle_after_head(&mut self, token: Token) { - match token { - Token::Character(c) if is_ascii_whitespace(c) => { - self.insert_character(c); - } - Token::Comment(data) => self.insert_comment(&data), - Token::Doctype { .. } => { /* ignore */ } - Token::StartTag { ref name, .. } if name == "html" => { - self.handle_in_body(token); - } - Token::StartTag { ref name, .. } if name == "body" => { - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.frameset_ok = false; - self.mode = InsertionMode::InBody; - } - } - Token::StartTag { ref name, .. } if name == "frameset" => { - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.mode = InsertionMode::InFrameset; - } - } - Token::StartTag { ref name, .. } - if matches!( - name.as_str(), - "base" - | "basefont" - | "bgsound" - | "link" - | "meta" - | "noframes" - | "script" - | "style" - | "template" - | "title" - ) => - { - // Push head back, process in InHead, then remove head again - if let Some(head) = self.head_pointer { - self.open_elements.push(StackEntry { - node_id: head, - name: "head".to_string(), - ns: Namespace::Html, - is_html_integration: false, - }); - } - self.handle_in_head(token); - // Remove head from stack if still there - if let Some(pos) = self.open_elements.iter().position(|e| e.name == "head") { - self.open_elements.remove(pos); - } - } - Token::EndTag { ref name } if name == "template" => { - self.handle_in_head(token); - } - Token::StartTag { ref name, .. } if name == "head" => { - // Parse error, ignore - } - Token::EndTag { ref name } if !matches!(name.as_str(), "body" | "html" | "br") => { - // Parse error, ignore - } - _ => { - self.insert_html_element("body", &[]); - self.mode = InsertionMode::InBody; - self.process_token(token); - } - } - } - - #[allow( - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::match_same_arms - )] - fn handle_in_body(&mut self, token: Token) { - match token { - Token::Character('\0') => { /* ignore */ } - Token::Character(c) if is_ascii_whitespace(c) => { - self.reconstruct_formatting(); - self.insert_character(c); - } - Token::Character(c) => { - self.reconstruct_formatting(); - self.insert_character(c); - self.frameset_ok = false; - } - Token::Comment(data) => self.insert_comment(&data), - Token::Doctype { .. } => { /* ignore */ } - Token::StartTag { ref name, .. } if name == "html" => { - // Merge attributes onto the existing html element — but - // ignore if there is a template on the stack. - if !self.open_elements.iter().any(|e| e.name == "template") { - if let Token::StartTag { attributes, .. } = token { - if let Some(html_entry) = self.open_elements.first() { - let html_id = html_entry.node_id; - for attr in &attributes { - if self.doc.attribute(html_id, &attr.name).is_none() { - if let NodeKind::Element { - ref mut attributes, .. - } = &mut self.doc.node_mut(html_id).kind - { - attributes.push(crate::tree::Attribute { - name: attr.name.clone(), - value: attr.value.clone(), - prefix: None, - namespace: None, - raw_value: None, - }); - } - } - } - } - } - } - } - Token::StartTag { ref name, .. } - if matches!( - name.as_str(), - "base" - | "basefont" - | "bgsound" - | "link" - | "meta" - | "noframes" - | "script" - | "style" - | "template" - | "title" - ) => - { - self.handle_in_head(token); - } - Token::EndTag { ref name } if name == "template" => { - self.handle_in_head(token); - } - Token::StartTag { ref name, .. } if name == "body" => { - // Merge attributes onto existing body — but ignore if there - // is a template element on the stack of open elements. - if let Token::StartTag { attributes, .. } = token { - if self.open_elements.len() >= 2 - && self.open_elements[1].name == "body" - && !self.open_elements.iter().any(|e| e.name == "template") - { - let body_id = self.open_elements[1].node_id; - self.frameset_ok = false; - for attr in &attributes { - if self.doc.attribute(body_id, &attr.name).is_none() { - if let NodeKind::Element { - ref mut attributes, .. - } = &mut self.doc.node_mut(body_id).kind - { - attributes.push(crate::tree::Attribute { - name: attr.name.clone(), - value: attr.value.clone(), - prefix: None, - namespace: None, - raw_value: None, - }); - } - } - } - } - } - } - Token::StartTag { ref name, .. } if name == "frameset" => { - // Ignore unless frameset_ok - if self.frameset_ok { - if let Token::StartTag { - name, attributes, .. - } = token - { - // Remove body from stack if present - if self.open_elements.len() >= 2 && self.open_elements[1].name == "body" { - let body_id = self.open_elements[1].node_id; - self.doc.detach(body_id); - while self.open_elements.len() > 1 { - self.open_elements.pop(); - } - } - self.insert_html_element(&name, &attributes); - self.mode = InsertionMode::InFrameset; - } - } - } - Token::Eof => { - if !self.template_modes.is_empty() { - self.handle_in_template(Token::Eof); - } - // Stop parsing - } - Token::EndTag { ref name } if name == "body" => { - if self.element_in_scope("body") { - self.mode = InsertionMode::AfterBody; - } - } - Token::EndTag { ref name } if name == "html" => { - if self.element_in_scope("body") { - self.mode = InsertionMode::AfterBody; - self.process_token(token); - } - } - Token::StartTag { ref name, .. } - if matches!( - name.as_str(), - "address" - | "article" - | "aside" - | "blockquote" - | "center" - | "details" - | "dialog" - | "dir" - | "div" - | "dl" - | "fieldset" - | "figcaption" - | "figure" - | "footer" - | "header" - | "hgroup" - | "main" - | "menu" - | "nav" - | "ol" - | "p" - | "search" - | "section" - | "summary" - | "ul" - ) => - { - if self.element_in_button_scope("p") { - self.close_p_element(); - } - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - } - } - Token::StartTag { ref name, .. } if is_heading(name) => { - if self.element_in_button_scope("p") { - self.close_p_element(); - } - if is_heading(self.current_node_name()) { - self.open_elements.pop(); - } - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - } - } - Token::StartTag { ref name, .. } if matches!(name.as_str(), "pre" | "listing") => { - if self.element_in_button_scope("p") { - self.close_p_element(); - } - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.skip_next_lf = true; - self.frameset_ok = false; - } - } - Token::StartTag { ref name, .. } if name == "form" => { - if self.form_pointer.is_some() - && !self.open_elements.iter().any(|e| e.name == "template") - { - // Parse error, ignore - } else { - if self.element_in_button_scope("p") { - self.close_p_element(); - } - if let Token::StartTag { - name, attributes, .. - } = token - { - let node_id = self.insert_html_element(&name, &attributes); - if !self.open_elements.iter().any(|e| e.name == "template") { - self.form_pointer = Some(node_id); - } - } - } - } - Token::StartTag { ref name, .. } if name == "li" => { - self.frameset_ok = false; - // Close any open li in list item scope - for i in (0..self.open_elements.len()).rev() { - let entry_name = self.open_elements[i].name.clone(); - if entry_name == "li" { - self.generate_implied_end_tags(Some("li")); - while let Some(e) = self.open_elements.pop() { - if e.name == "li" { - break; - } - } - break; - } - if is_special_element_ns(&entry_name, self.open_elements[i].ns) - && !matches!(entry_name.as_str(), "address" | "div" | "p") - { - break; - } - } - if self.element_in_button_scope("p") { - self.close_p_element(); - } - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - } - } - Token::StartTag { ref name, .. } if matches!(name.as_str(), "dd" | "dt") => { - self.frameset_ok = false; - for i in (0..self.open_elements.len()).rev() { - let entry_name = self.open_elements[i].name.clone(); - if matches!(entry_name.as_str(), "dd" | "dt") { - self.generate_implied_end_tags(Some(&entry_name)); - while let Some(e) = self.open_elements.pop() { - if e.name == entry_name { - break; - } - } - break; - } - if is_special_element_ns(&entry_name, self.open_elements[i].ns) - && !matches!(entry_name.as_str(), "address" | "div" | "p") - { - break; - } - } - if self.element_in_button_scope("p") { - self.close_p_element(); - } - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - } - } - Token::StartTag { ref name, .. } if name == "plaintext" => { - if self.element_in_button_scope("p") { - self.close_p_element(); - } - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.tokenizer.set_state(State::Plaintext); - } - } - Token::StartTag { ref name, .. } if name == "button" => { - if self.element_in_scope("button") { - self.generate_implied_end_tags(None); - while let Some(e) = self.open_elements.pop() { - if e.name == "button" { - break; - } - } - } - self.reconstruct_formatting(); - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.frameset_ok = false; - } - } - Token::EndTag { ref name } - if matches!( - name.as_str(), - "address" - | "article" - | "aside" - | "blockquote" - | "button" - | "center" - | "details" - | "dialog" - | "dir" - | "div" - | "dl" - | "fieldset" - | "figcaption" - | "figure" - | "footer" - | "header" - | "hgroup" - | "listing" - | "main" - | "menu" - | "nav" - | "ol" - | "pre" - | "search" - | "section" - | "summary" - | "ul" - ) => - { - if let Token::EndTag { name } = token { - if self.element_in_scope(&name) { - self.generate_implied_end_tags(None); - while let Some(e) = self.open_elements.pop() { - if e.name == name { - break; - } - } - } - } - } - Token::EndTag { ref name } if name == "form" => { - if !self.open_elements.iter().any(|e| e.name == "template") { - let node = self.form_pointer.take(); - if let Some(form_id) = node { - if self.element_in_scope("form") { - self.generate_implied_end_tags(None); - if let Some(pos) = - self.open_elements.iter().position(|e| e.node_id == form_id) - { - self.open_elements.remove(pos); - } - } - } - } else if self.element_in_scope("form") { - self.generate_implied_end_tags(None); - while let Some(e) = self.open_elements.pop() { - if e.name == "form" { - break; - } - } - } - } - Token::EndTag { ref name } if name == "p" => { - if !self.element_in_button_scope("p") { - self.insert_html_element("p", &[]); - } - self.close_p_element(); - } - Token::EndTag { ref name } if name == "li" => { - if self.element_in_list_item_scope("li") { - self.generate_implied_end_tags(Some("li")); - while let Some(e) = self.open_elements.pop() { - if e.name == "li" { - break; - } - } - } - } - Token::EndTag { ref name } if matches!(name.as_str(), "dd" | "dt") => { - if let Token::EndTag { name } = token { - if self.element_in_scope(&name) { - self.generate_implied_end_tags(Some(&name)); - while let Some(e) = self.open_elements.pop() { - if e.name == name { - break; - } - } - } - } - } - Token::EndTag { ref name } if is_heading(name) => { - if self.element_in_scope("h1") - || self.element_in_scope("h2") - || self.element_in_scope("h3") - || self.element_in_scope("h4") - || self.element_in_scope("h5") - || self.element_in_scope("h6") - { - self.generate_implied_end_tags(None); - while let Some(e) = self.open_elements.pop() { - if is_heading(&e.name) { - break; - } - } - } - } - Token::StartTag { ref name, .. } if name == "a" => { - // Check if there's already an 'a' between the end of the - // formatting list and the last marker (per spec §13.2.6.4.7). - let existing_a = { - let mut found = None; - for (i, entry) in self.active_formatting.iter().enumerate().rev() { - match entry { - FormatEntry::Marker => break, - FormatEntry::Element { name, .. } if name == "a" => { - found = Some(i); - break; - } - FormatEntry::Element { .. } => {} - } - } - found - }; - if existing_a.is_some() { - self.adoption_agency("a"); - // Remove from formatting list if still there (only - // between end and last marker, matching the search above). - let mut remove_pos = None; - for (i, entry) in self.active_formatting.iter().enumerate().rev() { - match entry { - FormatEntry::Marker => break, - FormatEntry::Element { name, .. } if name == "a" => { - remove_pos = Some(i); - break; - } - FormatEntry::Element { .. } => {} - } - } - if let Some(pos) = remove_pos { - let entry = self.active_formatting.remove(pos); - if let FormatEntry::Element { node_id, .. } = entry { - if let Some(stack_pos) = - self.open_elements.iter().position(|e| e.node_id == node_id) - { - self.open_elements.remove(stack_pos); - } - } - } - } - self.reconstruct_formatting(); - if let Token::StartTag { - name, attributes, .. - } = token - { - let node_id = self.insert_html_element(&name, &attributes); - self.push_formatting(node_id, &name, &attributes); - } - } - Token::StartTag { ref name, .. } - if matches!( - name.as_str(), - "b" | "big" - | "code" - | "em" - | "font" - | "i" - | "s" - | "small" - | "strike" - | "strong" - | "tt" - | "u" - ) => - { - self.reconstruct_formatting(); - if let Token::StartTag { - name, attributes, .. - } = token - { - let node_id = self.insert_html_element(&name, &attributes); - self.push_formatting(node_id, &name, &attributes); - } - } - Token::StartTag { ref name, .. } if name == "nobr" => { - self.reconstruct_formatting(); - if self.element_in_scope("nobr") { - self.adoption_agency("nobr"); - self.reconstruct_formatting(); - } - if let Token::StartTag { - name, attributes, .. - } = token - { - let node_id = self.insert_html_element(&name, &attributes); - self.push_formatting(node_id, &name, &attributes); - } - } - Token::EndTag { ref name } if is_formatting_element(name) => { - if let Token::EndTag { name } = token { - self.adoption_agency(&name); - } - } - Token::StartTag { ref name, .. } - if matches!(name.as_str(), "applet" | "marquee" | "object") => - { - self.reconstruct_formatting(); - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.push_formatting_marker(); - self.frameset_ok = false; - } - } - Token::EndTag { ref name } - if matches!(name.as_str(), "applet" | "marquee" | "object") => - { - if let Token::EndTag { name } = token { - if self.element_in_scope(&name) { - self.generate_implied_end_tags(None); - while let Some(e) = self.open_elements.pop() { - if e.name == name { - break; - } - } - self.clear_formatting_to_marker(); - } - } - } - Token::StartTag { ref name, .. } if name == "table" => { - if self.quirks_mode != QuirksMode::Quirks && self.element_in_button_scope("p") { - self.close_p_element(); - } - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.frameset_ok = false; - self.mode = InsertionMode::InTable; - } - } - Token::EndTag { ref name } if name == "br" => { - // Parse error — treat as start tag - self.reconstruct_formatting(); - self.insert_html_element("br", &[]); - self.open_elements.pop(); - self.frameset_ok = false; - } - Token::StartTag { ref name, .. } - if matches!( - name.as_str(), - "area" | "br" | "embed" | "img" | "keygen" | "wbr" - ) => - { - self.reconstruct_formatting(); - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.open_elements.pop(); // void - self.frameset_ok = false; - } - } - Token::StartTag { ref name, .. } if name == "input" => { - self.reconstruct_formatting(); - if let Token::StartTag { - name, attributes, .. - } = token - { - let is_hidden = attributes - .iter() - .any(|a| a.name == "type" && a.value.eq_ignore_ascii_case("hidden")); - self.insert_html_element(&name, &attributes); - self.open_elements.pop(); // void - if !is_hidden { - self.frameset_ok = false; - } - } - } - Token::StartTag { ref name, .. } - if matches!(name.as_str(), "param" | "source" | "track") => - { - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.open_elements.pop(); // void - } - } - Token::StartTag { ref name, .. } if name == "hr" => { - if self.element_in_button_scope("p") { - self.close_p_element(); - } - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.open_elements.pop(); // void - self.frameset_ok = false; - } - } - Token::StartTag { ref name, .. } if name == "image" => { - // Parse error — change to "img" - self.reconstruct_formatting(); - if let Token::StartTag { attributes, .. } = token { - self.insert_html_element("img", &attributes); - self.open_elements.pop(); - self.frameset_ok = false; - } - } - Token::StartTag { ref name, .. } if name == "textarea" => { - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.skip_next_lf = true; - self.tokenizer.set_state(State::RcData); - self.tokenizer.set_last_start_tag(&name); - self.original_mode = self.mode; - self.frameset_ok = false; - self.mode = InsertionMode::Text; - } - } - Token::StartTag { ref name, .. } if name == "xmp" => { - if self.element_in_button_scope("p") { - self.close_p_element(); - } - self.reconstruct_formatting(); - self.frameset_ok = false; - if let Token::StartTag { - name, attributes, .. - } = token - { - self.parse_raw_text(&name, &attributes); - } - } - Token::StartTag { ref name, .. } if name == "iframe" => { - self.frameset_ok = false; - if let Token::StartTag { - name, attributes, .. - } = token - { - self.parse_raw_text(&name, &attributes); - } - } - Token::StartTag { ref name, .. } if name == "noembed" => { - if let Token::StartTag { - name, attributes, .. - } = token - { - self.parse_raw_text(&name, &attributes); - } - } - Token::StartTag { ref name, .. } if name == "noscript" && self.scripting => { - if let Token::StartTag { - name, attributes, .. - } = token - { - self.parse_raw_text(&name, &attributes); - } - } - Token::StartTag { ref name, .. } if name == "select" => { - self.reconstruct_formatting(); - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.frameset_ok = false; - match self.mode { - InsertionMode::InTable - | InsertionMode::InCaption - | InsertionMode::InTableBody - | InsertionMode::InRow - | InsertionMode::InCell => { - self.mode = InsertionMode::InSelectInTable; - } - _ => { - self.mode = InsertionMode::InSelect; - } - } - } - } - Token::StartTag { ref name, .. } if matches!(name.as_str(), "optgroup" | "option") => { - if self.current_node_name() == "option" { - self.open_elements.pop(); - } - self.reconstruct_formatting(); - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - } - } - Token::StartTag { ref name, .. } if matches!(name.as_str(), "rb" | "rtc") => { - if self.element_in_scope("ruby") { - self.generate_implied_end_tags(None); - } - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - } - } - Token::StartTag { ref name, .. } if matches!(name.as_str(), "rp" | "rt") => { - if self.element_in_scope("ruby") { - self.generate_implied_end_tags(Some("rtc")); - } - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - } - } - Token::StartTag { ref name, .. } if name == "math" => { - self.reconstruct_formatting(); - if let Token::StartTag { - name: _, - attributes, - self_closing, - .. - } = token - { - // Adjust MathML attributes and foreign attributes per spec. - let adjusted: Vec<tokenizer::Attribute> = attributes - .iter() - .map(|a| tokenizer::Attribute { - name: adjust_mathml_attributes(&a.name).to_string(), - value: a.value.clone(), - }) - .collect(); - self.insert_foreign_element("math", &adjusted, Namespace::MathMl); - if self_closing { - self.open_elements.pop(); - } - } - } - Token::StartTag { ref name, .. } if name == "svg" => { - self.reconstruct_formatting(); - if let Token::StartTag { - name: _, - attributes, - self_closing, - .. - } = token - { - // Adjust SVG attributes and foreign attributes per spec. - let adjusted: Vec<tokenizer::Attribute> = attributes - .iter() - .map(|a| tokenizer::Attribute { - name: adjust_svg_attributes(&a.name).to_string(), - value: a.value.clone(), - }) - .collect(); - self.insert_foreign_element("svg", &adjusted, Namespace::Svg); - if self_closing { - self.open_elements.pop(); - } - } - } - Token::StartTag { ref name, .. } - if matches!( - name.as_str(), - "caption" - | "col" - | "colgroup" - | "frame" - | "head" - | "tbody" - | "td" - | "tfoot" - | "th" - | "thead" - | "tr" - ) => - { - // Parse error, ignore - } - Token::StartTag { - name, attributes, .. - } => { - // Any other start tag - self.reconstruct_formatting(); - self.insert_html_element(&name, &attributes); - } - Token::EndTag { name } => { - // Any other end tag - self.handle_any_other_end_tag(&name); - } - } - } - - fn handle_any_other_end_tag(&mut self, name: &str) { - for i in (0..self.open_elements.len()).rev() { - if self.open_elements[i].name == name && self.open_elements[i].ns == Namespace::Html { - self.generate_implied_end_tags(Some(name)); - while self.open_elements.len() > i { - self.open_elements.pop(); - } - return; - } - if is_special_element_ns(&self.open_elements[i].name, self.open_elements[i].ns) { - return; // Parse error, ignore - } - } - } - - #[allow(clippy::needless_pass_by_value)] - fn handle_text(&mut self, token: Token) { - match token { - Token::Character(c) => { - self.insert_character(c); - } - Token::Eof => { - self.open_elements.pop(); - self.mode = self.original_mode; - self.process_token(Token::Eof); - } - Token::EndTag { .. } => { - self.open_elements.pop(); - self.mode = self.original_mode; - } - _ => {} - } - } - - #[allow(clippy::too_many_lines)] - fn handle_in_table(&mut self, token: Token) { - match token { - Token::Character(_) - if matches!( - self.current_node_name(), - "table" | "tbody" | "tfoot" | "thead" | "tr" - ) => - { - self.pending_table_chars.clear(); - self.original_mode = self.mode; - self.mode = InsertionMode::InTableText; - self.process_token(token); - } - Token::Comment(data) => self.insert_comment(&data), - Token::Doctype { .. } => { /* ignore */ } - Token::StartTag { ref name, .. } if name == "caption" => { - self.clear_stack_back_to_table_context(); - self.push_formatting_marker(); - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.mode = InsertionMode::InCaption; - } - } - Token::StartTag { ref name, .. } if name == "colgroup" => { - self.clear_stack_back_to_table_context(); - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.mode = InsertionMode::InColumnGroup; - } - } - Token::StartTag { ref name, .. } if name == "col" => { - self.clear_stack_back_to_table_context(); - self.insert_html_element("colgroup", &[]); - self.mode = InsertionMode::InColumnGroup; - self.process_token(token); - } - Token::StartTag { ref name, .. } - if matches!(name.as_str(), "tbody" | "tfoot" | "thead") => - { - self.clear_stack_back_to_table_context(); - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.mode = InsertionMode::InTableBody; - } - } - Token::StartTag { ref name, .. } if matches!(name.as_str(), "td" | "th" | "tr") => { - self.clear_stack_back_to_table_context(); - self.insert_html_element("tbody", &[]); - self.mode = InsertionMode::InTableBody; - self.process_token(token); - } - Token::StartTag { ref name, .. } if name == "table" => { - if self.element_in_table_scope("table") { - while let Some(e) = self.open_elements.pop() { - if e.name == "table" { - break; - } - } - self.reset_insertion_mode(); - self.process_token(token); - } - } - Token::EndTag { ref name } if name == "table" => { - if self.element_in_table_scope("table") { - while let Some(e) = self.open_elements.pop() { - if e.name == "table" { - break; - } - } - self.reset_insertion_mode(); - } - } - Token::EndTag { ref name } - if matches!( - name.as_str(), - "body" - | "caption" - | "col" - | "colgroup" - | "html" - | "tbody" - | "td" - | "tfoot" - | "th" - | "thead" - | "tr" - ) => - { - // Parse error, ignore - } - Token::StartTag { ref name, .. } - if matches!(name.as_str(), "style" | "script" | "template") => - { - self.handle_in_head(token); - } - Token::EndTag { ref name } if name == "template" => { - self.handle_in_head(token); - } - Token::StartTag { ref name, .. } if name == "input" => { - if let Token::StartTag { ref attributes, .. } = token { - let is_hidden = attributes - .iter() - .any(|a| a.name == "type" && a.value.eq_ignore_ascii_case("hidden")); - if is_hidden { - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.open_elements.pop(); - } - } else { - self.foster_parenting = true; - self.handle_in_body(token); - self.foster_parenting = false; - } - } - } - Token::StartTag { ref name, .. } if name == "form" => { - if self.form_pointer.is_none() - && !self.open_elements.iter().any(|e| e.name == "template") - { - if let Token::StartTag { - name, attributes, .. - } = token - { - let node_id = self.insert_html_element(&name, &attributes); - self.form_pointer = Some(node_id); - self.open_elements.pop(); - } - } - } - Token::Eof => { - self.handle_in_body(token); - } - _ => { - // Foster parenting - self.foster_parenting = true; - self.handle_in_body(token); - self.foster_parenting = false; - } - } - } - - fn handle_in_table_text(&mut self, token: Token) { - match token { - Token::Character('\0') => { /* ignore */ } - Token::Character(c) => { - self.pending_table_chars.push(c); - } - _ => { - let chars: Vec<char> = std::mem::take(&mut self.pending_table_chars); - let has_non_ws = chars.iter().any(|c| !is_ascii_whitespace(*c)); - if has_non_ws { - // Foster parent each character - self.foster_parenting = true; - for c in chars { - self.reconstruct_formatting(); - self.insert_character(c); - if !is_ascii_whitespace(c) { - self.frameset_ok = false; - } - } - self.foster_parenting = false; - } else { - for c in chars { - self.insert_character(c); - } - } - self.mode = self.original_mode; - self.process_token(token); - } - } - } - - fn handle_in_caption(&mut self, token: Token) { - match token { - Token::EndTag { ref name } if name == "caption" => { - if self.element_in_table_scope("caption") { - self.generate_implied_end_tags(None); - while let Some(e) = self.open_elements.pop() { - if e.name == "caption" { - break; - } - } - self.clear_formatting_to_marker(); - self.mode = InsertionMode::InTable; - } - } - Token::StartTag { ref name, .. } - if matches!( - name.as_str(), - "caption" - | "col" - | "colgroup" - | "tbody" - | "td" - | "tfoot" - | "th" - | "thead" - | "tr" - ) => - { - if self.element_in_table_scope("caption") { - self.generate_implied_end_tags(None); - while let Some(e) = self.open_elements.pop() { - if e.name == "caption" { - break; - } - } - self.clear_formatting_to_marker(); - self.mode = InsertionMode::InTable; - self.process_token(token); - } - } - Token::EndTag { ref name } if name == "table" => { - if self.element_in_table_scope("caption") { - self.generate_implied_end_tags(None); - while let Some(e) = self.open_elements.pop() { - if e.name == "caption" { - break; - } - } - self.clear_formatting_to_marker(); - self.mode = InsertionMode::InTable; - self.process_token(token); - } - } - Token::EndTag { ref name } - if matches!( - name.as_str(), - "body" - | "col" - | "colgroup" - | "html" - | "tbody" - | "td" - | "tfoot" - | "th" - | "thead" - | "tr" - ) => - { - // ignore - } - _ => { - self.handle_in_body(token); - } - } - } - - fn handle_in_column_group(&mut self, token: Token) { - match token { - Token::Character(c) if is_ascii_whitespace(c) => { - self.insert_character(c); - } - Token::Comment(data) => self.insert_comment(&data), - Token::Doctype { .. } => { /* ignore */ } - Token::StartTag { ref name, .. } if name == "html" => { - self.handle_in_body(token); - } - Token::StartTag { ref name, .. } if name == "col" => { - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.open_elements.pop(); // void - } - } - Token::EndTag { ref name } if name == "colgroup" => { - if self.current_node_name() == "colgroup" { - self.open_elements.pop(); - self.mode = InsertionMode::InTable; - } - // else: parse error, ignore - } - Token::EndTag { ref name } if name == "col" => { - // parse error, ignore - } - Token::StartTag { ref name, .. } if name == "template" => { - self.handle_in_head(token); - } - Token::EndTag { ref name } if name == "template" => { - self.handle_in_head(token); - } - Token::Eof => { - self.handle_in_body(token); - } - _ => { - if self.current_node_name() == "colgroup" { - self.open_elements.pop(); - self.mode = InsertionMode::InTable; - self.process_token(token); - } - } - } - } - - fn handle_in_table_body(&mut self, token: Token) { - match token { - Token::StartTag { ref name, .. } if name == "tr" => { - self.clear_stack_back_to_table_body_context(); - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.mode = InsertionMode::InRow; - } - } - Token::StartTag { ref name, .. } if matches!(name.as_str(), "th" | "td") => { - self.clear_stack_back_to_table_body_context(); - self.insert_html_element("tr", &[]); - self.mode = InsertionMode::InRow; - self.process_token(token); - } - Token::EndTag { ref name } if matches!(name.as_str(), "tbody" | "tfoot" | "thead") => { - if let Token::EndTag { name } = token { - if self.element_in_table_scope(&name) { - self.clear_stack_back_to_table_body_context(); - self.open_elements.pop(); - self.mode = InsertionMode::InTable; - } - } - } - Token::StartTag { ref name, .. } - if matches!( - name.as_str(), - "caption" | "col" | "colgroup" | "tbody" | "tfoot" | "thead" - ) => - { - if self.element_in_table_scope("tbody") - || self.element_in_table_scope("thead") - || self.element_in_table_scope("tfoot") - { - self.clear_stack_back_to_table_body_context(); - self.open_elements.pop(); - self.mode = InsertionMode::InTable; - self.process_token(token); - } - } - Token::EndTag { ref name } if name == "table" => { - if self.element_in_table_scope("tbody") - || self.element_in_table_scope("thead") - || self.element_in_table_scope("tfoot") - { - self.clear_stack_back_to_table_body_context(); - self.open_elements.pop(); - self.mode = InsertionMode::InTable; - self.process_token(token); - } - } - Token::EndTag { ref name } - if matches!( - name.as_str(), - "body" | "caption" | "col" | "colgroup" | "html" | "td" | "th" | "tr" - ) => - { - // ignore - } - _ => { - self.handle_in_table(token); - } - } - } - - fn handle_in_row(&mut self, token: Token) { - match token { - Token::StartTag { ref name, .. } if matches!(name.as_str(), "th" | "td") => { - self.clear_stack_back_to_table_row_context(); - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.mode = InsertionMode::InCell; - self.push_formatting_marker(); - } - } - Token::EndTag { ref name } if name == "tr" => { - if self.element_in_table_scope("tr") { - self.clear_stack_back_to_table_row_context(); - self.open_elements.pop(); - self.mode = InsertionMode::InTableBody; - } - } - Token::StartTag { ref name, .. } - if matches!( - name.as_str(), - "caption" | "col" | "colgroup" | "tbody" | "tfoot" | "thead" | "tr" - ) => - { - if self.element_in_table_scope("tr") { - self.clear_stack_back_to_table_row_context(); - self.open_elements.pop(); - self.mode = InsertionMode::InTableBody; - self.process_token(token); - } - } - Token::EndTag { ref name } if name == "table" => { - if self.element_in_table_scope("tr") { - self.clear_stack_back_to_table_row_context(); - self.open_elements.pop(); - self.mode = InsertionMode::InTableBody; - self.process_token(token); - } - } - Token::EndTag { ref name } if matches!(name.as_str(), "tbody" | "tfoot" | "thead") => { - if self.element_in_table_scope(name) && self.element_in_table_scope("tr") { - self.clear_stack_back_to_table_row_context(); - self.open_elements.pop(); - self.mode = InsertionMode::InTableBody; - self.process_token(token); - } - } - Token::EndTag { ref name } - if matches!( - name.as_str(), - "body" | "caption" | "col" | "colgroup" | "html" | "td" | "th" - ) => - { - // ignore - } - _ => { - self.handle_in_table(token); - } - } - } - - fn handle_in_cell(&mut self, token: Token) { - match token { - Token::EndTag { ref name } if matches!(name.as_str(), "td" | "th") => { - if let Token::EndTag { name } = token { - if self.element_in_table_scope(&name) { - self.generate_implied_end_tags(None); - while let Some(e) = self.open_elements.pop() { - if e.name == name && e.ns == Namespace::Html { - break; - } - } - self.clear_formatting_to_marker(); - self.mode = InsertionMode::InRow; - } - } - } - Token::StartTag { ref name, .. } - if matches!( - name.as_str(), - "caption" - | "col" - | "colgroup" - | "tbody" - | "td" - | "tfoot" - | "th" - | "thead" - | "tr" - ) => - { - if self.element_in_table_scope("td") || self.element_in_table_scope("th") { - self.close_cell(); - self.process_token(token); - } - } - Token::EndTag { ref name } - if matches!( - name.as_str(), - "body" | "caption" | "col" | "colgroup" | "html" - ) => - { - // ignore - } - Token::EndTag { ref name } - if matches!(name.as_str(), "table" | "tbody" | "tfoot" | "thead" | "tr") => - { - if let Token::EndTag { ref name } = token { - if self.element_in_table_scope(name) { - self.close_cell(); - self.process_token(token); - } - } - } - _ => { - self.handle_in_body(token); - } - } - } - - fn close_cell(&mut self) { - self.generate_implied_end_tags(None); - while let Some(e) = self.open_elements.pop() { - if matches!(e.name.as_str(), "td" | "th") { - break; - } - } - self.clear_formatting_to_marker(); - self.mode = InsertionMode::InRow; - } - - #[allow(clippy::too_many_lines, clippy::match_same_arms)] - fn handle_in_select(&mut self, token: Token) { - match token { - Token::Character('\0') => { /* ignore */ } - Token::Character(c) => { - self.reconstruct_formatting(); - self.insert_character(c); - } - Token::Comment(data) => self.insert_comment(&data), - Token::Doctype { .. } => { /* ignore */ } - Token::StartTag { ref name, .. } if name == "html" => { - self.handle_in_body(token); - } - Token::StartTag { ref name, .. } if name == "option" => { - if self.current_node_name() == "option" { - self.open_elements.pop(); - } - self.reconstruct_formatting(); - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - } - } - Token::StartTag { ref name, .. } if name == "optgroup" => { - if self.current_node_name() == "option" { - self.open_elements.pop(); - } - if self.current_node_name() == "optgroup" { - self.open_elements.pop(); - } - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - } - } - Token::StartTag { ref name, .. } if name == "hr" => { - if self.current_node_name() == "option" { - self.open_elements.pop(); - } - if self.current_node_name() == "optgroup" { - self.open_elements.pop(); - } - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.open_elements.pop(); // void - } - } - Token::EndTag { ref name } if name == "optgroup" => { - if self.current_node_name() == "option" - && self.open_elements.len() >= 2 - && self.open_elements[self.open_elements.len() - 2].name == "optgroup" - { - self.open_elements.pop(); - } - if self.current_node_name() == "optgroup" { - self.open_elements.pop(); - } - } - Token::EndTag { ref name } if name == "option" => { - if self.current_node_name() == "option" { - self.open_elements.pop(); - } - } - Token::EndTag { ref name } if name == "select" => { - if self.element_in_select_scope("select") { - while let Some(e) = self.open_elements.pop() { - if e.name == "select" { - break; - } - } - self.reset_insertion_mode(); - } - } - Token::StartTag { ref name, .. } if name == "select" => { - // Parse error — act as end tag - if self.element_in_select_scope("select") { - while let Some(e) = self.open_elements.pop() { - if e.name == "select" { - break; - } - } - self.reset_insertion_mode(); - } - } - Token::StartTag { ref name, .. } if matches!(name.as_str(), "input" | "textarea") => { - // Close select and reprocess - if self.element_in_select_scope("select") { - while let Some(e) = self.open_elements.pop() { - if e.name == "select" { - break; - } - } - self.reset_insertion_mode(); - self.process_token(token); - } else if self - .fragment_context - .as_ref() - .is_some_and(|(n, ns)| n == "select" && *ns == Namespace::Html) - { - // Fragment case: context is select but it's not on the stack. - // Switch to InBody and reprocess. - self.mode = InsertionMode::InBody; - self.process_token(token); - } - } - Token::StartTag { ref name, .. } if matches!(name.as_str(), "script" | "template") => { - self.handle_in_head(token); - } - Token::EndTag { ref name } if name == "template" => { - self.handle_in_head(token); - } - // New select content model: allow certain elements inside <select>. - Token::StartTag { ref name, .. } - if matches!( - name.as_str(), - "div" | "button" | "datalist" | "selectedcontent" - ) => - { - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - } - } - Token::EndTag { ref name } - if matches!( - name.as_str(), - "div" | "button" | "datalist" | "selectedcontent" - ) => - { - if self.element_in_scope(name) { - self.generate_implied_end_tags(Some(name)); - while let Some(e) = self.open_elements.pop() { - if e.name == *name { - break; - } - } - } - } - Token::StartTag { ref name, .. } if name == "svg" => { - self.reconstruct_formatting(); - if let Token::StartTag { - name: _, - attributes, - self_closing, - .. - } = token - { - let adjusted: Vec<tokenizer::Attribute> = attributes - .iter() - .map(|a| tokenizer::Attribute { - name: adjust_svg_attributes(&a.name).to_string(), - value: a.value.clone(), - }) - .collect(); - self.insert_foreign_element("svg", &adjusted, Namespace::Svg); - if self_closing { - self.open_elements.pop(); - } - } - } - Token::StartTag { ref name, .. } if name == "math" => { - self.reconstruct_formatting(); - if let Token::StartTag { - name: _, - attributes, - self_closing, - .. - } = token - { - let adjusted: Vec<tokenizer::Attribute> = attributes - .iter() - .map(|a| tokenizer::Attribute { - name: adjust_mathml_attributes(&a.name).to_string(), - value: a.value.clone(), - }) - .collect(); - self.insert_foreign_element("math", &adjusted, Namespace::MathMl); - if self_closing { - self.open_elements.pop(); - } - } - } - // New select content model: allow most other start tags - // by processing them via InBody rules. - Token::StartTag { .. } => { - self.handle_in_body(token); - } - // End tags for elements opened inside select via InBody. - Token::EndTag { ref name } - if self - .open_elements - .iter() - .rev() - .take_while(|e| e.name != "select") - .any(|e| e.name == *name && e.ns == Namespace::Html) => - { - self.handle_in_body(token); - } - Token::Eof => { - self.handle_in_body(token); - } - Token::EndTag { .. } => { /* ignore */ } - } - } - - fn handle_in_select_in_table(&mut self, token: Token) { - match token { - Token::StartTag { ref name, .. } - if matches!( - name.as_str(), - "caption" | "table" | "tbody" | "tfoot" | "thead" | "tr" | "td" | "th" - ) => - { - while let Some(e) = self.open_elements.pop() { - if e.name == "select" { - break; - } - } - self.reset_insertion_mode(); - self.process_token(token); - } - Token::EndTag { ref name } - if matches!( - name.as_str(), - "caption" | "table" | "tbody" | "tfoot" | "thead" | "tr" | "td" | "th" - ) => - { - if let Token::EndTag { ref name } = token { - if self.element_in_table_scope(name) { - while let Some(e) = self.open_elements.pop() { - if e.name == "select" { - break; - } - } - self.reset_insertion_mode(); - self.process_token(token); - } - } - } - _ => { - self.handle_in_select(token); - } - } - } - - #[allow(clippy::match_same_arms)] - fn handle_in_template(&mut self, token: Token) { - match token { - Token::Character(_) | Token::Comment(_) | Token::Doctype { .. } => { - self.handle_in_body(token); - } - Token::StartTag { ref name, .. } - if matches!( - name.as_str(), - "base" - | "basefont" - | "bgsound" - | "link" - | "meta" - | "noframes" - | "script" - | "style" - | "template" - | "title" - ) => - { - self.handle_in_head(token); - } - Token::EndTag { ref name } if name == "template" => { - self.handle_in_head(token); - } - Token::StartTag { ref name, .. } - if matches!( - name.as_str(), - "caption" | "colgroup" | "tbody" | "tfoot" | "thead" - ) => - { - self.template_modes.pop(); - self.template_modes.push(InsertionMode::InTable); - self.mode = InsertionMode::InTable; - self.process_token(token); - } - Token::StartTag { ref name, .. } if name == "col" => { - self.template_modes.pop(); - self.template_modes.push(InsertionMode::InColumnGroup); - self.mode = InsertionMode::InColumnGroup; - self.process_token(token); - } - Token::StartTag { ref name, .. } if name == "tr" => { - self.template_modes.pop(); - self.template_modes.push(InsertionMode::InTableBody); - self.mode = InsertionMode::InTableBody; - self.process_token(token); - } - Token::StartTag { ref name, .. } if matches!(name.as_str(), "td" | "th") => { - self.template_modes.pop(); - self.template_modes.push(InsertionMode::InRow); - self.mode = InsertionMode::InRow; - self.process_token(token); - } - Token::Eof => { - if self - .open_elements - .iter() - .any(|e| e.name == "template" && e.ns == Namespace::Html) - { - self.generate_all_implied_end_tags(); - while let Some(e) = self.open_elements.pop() { - if e.name == "template" && e.ns == Namespace::Html { - break; - } - } - self.clear_formatting_to_marker(); - self.template_modes.pop(); - self.reset_insertion_mode(); - self.process_token(Token::Eof); - } - // else: stop parsing - } - Token::StartTag { .. } => { - self.template_modes.pop(); - self.template_modes.push(InsertionMode::InBody); - self.mode = InsertionMode::InBody; - self.process_token(token); - } - Token::EndTag { .. } => { - // ignore - } - } - } - - #[allow(clippy::match_same_arms)] - fn handle_after_body(&mut self, token: Token) { - match token { - Token::Character(c) if is_ascii_whitespace(c) => { - self.handle_in_body(token); - } - Token::Comment(data) => { - // Append to the html element (first in stack) - if let Some(html_entry) = self.open_elements.first() { - let html_id = html_entry.node_id; - let comment_id = self.doc.create_node(NodeKind::Comment { content: data }); - self.doc.append_child(html_id, comment_id); - } - } - Token::Doctype { .. } => { /* ignore */ } - Token::StartTag { ref name, .. } if name == "html" => { - self.handle_in_body(token); - } - Token::EndTag { ref name } if name == "html" => { - if self.fragment_context.is_some() { - // Fragment case: ignore the token (parse error). - } else { - self.mode = InsertionMode::AfterAfterBody; - } - } - Token::Eof => { - // Stop parsing - } - _ => { - self.mode = InsertionMode::InBody; - self.process_token(token); - } - } - } - - #[allow(clippy::match_same_arms)] - fn handle_in_frameset(&mut self, token: Token) { - match token { - Token::Character(c) if is_ascii_whitespace(c) => { - self.insert_character(c); - } - Token::Comment(data) => self.insert_comment(&data), - Token::Doctype { .. } => { /* ignore */ } - Token::StartTag { ref name, .. } if name == "html" => { - self.handle_in_body(token); - } - Token::StartTag { ref name, .. } if name == "frameset" => { - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - } - } - Token::EndTag { ref name } - if name == "frameset" && self.current_node_name() != "html" => - { - self.open_elements.pop(); - if self.current_node_name() != "frameset" { - self.mode = InsertionMode::AfterFrameset; - } - } - Token::StartTag { ref name, .. } if name == "frame" => { - if let Token::StartTag { - name, attributes, .. - } = token - { - self.insert_html_element(&name, &attributes); - self.open_elements.pop(); // void - } - } - Token::StartTag { ref name, .. } if name == "noframes" => { - self.handle_in_head(token); - } - Token::Eof => { - // Stop parsing - } - _ => { /* ignore */ } - } - } - - #[allow(clippy::match_same_arms)] - fn handle_after_frameset(&mut self, token: Token) { - match token { - Token::Character(c) if is_ascii_whitespace(c) => { - self.insert_character(c); - } - Token::Comment(data) => self.insert_comment(&data), - Token::Doctype { .. } => { /* ignore */ } - Token::StartTag { ref name, .. } if name == "html" => { - self.handle_in_body(token); - } - Token::EndTag { ref name } if name == "html" => { - self.mode = InsertionMode::AfterAfterFrameset; - } - Token::StartTag { ref name, .. } if name == "noframes" => { - self.handle_in_head(token); - } - Token::Eof => { - // Stop parsing - } - _ => { /* ignore */ } - } - } - - #[allow(clippy::match_same_arms)] - fn handle_after_after_body(&mut self, token: Token) { - match token { - Token::Comment(data) => { - self.insert_comment_at_document(&data); - } - Token::Doctype { .. } | Token::Character(' ' | '\t' | '\n' | '\x0C' | '\r') => { - self.handle_in_body(token); - } - Token::StartTag { ref name, .. } if name == "html" => { - self.handle_in_body(token); - } - Token::Eof => { - // Stop parsing - } - _ => { - self.mode = InsertionMode::InBody; - self.process_token(token); - } - } - } - - #[allow(clippy::match_same_arms)] - fn handle_after_after_frameset(&mut self, token: Token) { - match token { - Token::Comment(data) => { - self.insert_comment_at_document(&data); - } - Token::Doctype { .. } | Token::Character(' ' | '\t' | '\n' | '\x0C' | '\r') => { - self.handle_in_body(token); - } - Token::StartTag { ref name, .. } if name == "html" => { - self.handle_in_body(token); - } - Token::StartTag { ref name, .. } if name == "noframes" => { - self.handle_in_head(token); - } - Token::Eof => { - // Stop parsing - } - _ => { /* ignore */ } - } - } - - // ----------------------------------------------------------------------- - // Stack clearing helpers - // ----------------------------------------------------------------------- - - fn clear_stack_back_to_table_context(&mut self) { - while !self.open_elements.is_empty() { - if matches!(self.current_node_name(), "table" | "template" | "html") { - break; - } - self.open_elements.pop(); - } - } - - fn clear_stack_back_to_table_body_context(&mut self) { - while !self.open_elements.is_empty() { - if matches!( - self.current_node_name(), - "tbody" | "tfoot" | "thead" | "template" | "html" - ) { - break; - } - self.open_elements.pop(); - } - } - - fn clear_stack_back_to_table_row_context(&mut self) { - while !self.open_elements.is_empty() { - if matches!(self.current_node_name(), "tr" | "template" | "html") { - break; - } - self.open_elements.pop(); - } - } - - fn reset_insertion_mode(&mut self) { - for i in (0..self.open_elements.len()).rev() { - let last = i == 0; - // Per WHATWG §13.2.4.1: when last is true and this is fragment - // parsing, use the context element instead of the stack element. - let name = if last { - if let Some((ref ctx_name, _)) = self.fragment_context { - ctx_name.clone() - } else { - self.open_elements[i].name.clone() - } - } else { - self.open_elements[i].name.clone() - }; - match name.as_str() { - "select" => { - if !last { - // Walk up to find if we're in a table - for j in (0..i).rev() { - match self.open_elements[j].name.as_str() { - "template" => break, - "table" => { - self.mode = InsertionMode::InSelectInTable; - return; - } - _ => {} - } - } - } - self.mode = InsertionMode::InSelect; - return; - } - "td" | "th" if !last => { - self.mode = InsertionMode::InCell; - return; - } - "tr" => { - self.mode = InsertionMode::InRow; - return; - } - "tbody" | "thead" | "tfoot" => { - self.mode = InsertionMode::InTableBody; - return; - } - "caption" => { - self.mode = InsertionMode::InCaption; - return; - } - "colgroup" => { - self.mode = InsertionMode::InColumnGroup; - return; - } - "table" => { - self.mode = InsertionMode::InTable; - return; - } - "template" => { - self.mode = self - .template_modes - .last() - .copied() - .unwrap_or(InsertionMode::InBody); - return; - } - "head" if !last => { - self.mode = InsertionMode::InHead; - return; - } - "body" => { - self.mode = InsertionMode::InBody; - return; - } - "frameset" => { - self.mode = InsertionMode::InFrameset; - return; - } - "html" => { - if self.head_pointer.is_none() { - self.mode = InsertionMode::BeforeHead; - } else { - self.mode = InsertionMode::AfterHead; - } - return; - } - _ => {} - } - if last { - self.mode = InsertionMode::InBody; - return; - } - } - self.mode = InsertionMode::InBody; - } -} - -// --------------------------------------------------------------------------- -// Utility -// --------------------------------------------------------------------------- - -fn is_ascii_whitespace(c: char) -> bool { - matches!(c, ' ' | '\t' | '\n' | '\x0C' | '\r') -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - - /// Helper: parse HTML5, return the Document. - fn parse(input: &str) -> Document { - parse_html5(input).unwrap() - } - - /// Walk children and collect their node names (or "#text" / "#comment"). - fn child_names(doc: &Document, id: NodeId) -> Vec<String> { - doc.children(id) - .map(|c| match &doc.node(c).kind { - NodeKind::Element { name, .. } => name.clone(), - NodeKind::Text { .. } => "#text".to_string(), - NodeKind::Comment { .. } => "#comment".to_string(), - NodeKind::DocumentType { .. } => "#doctype".to_string(), - NodeKind::Document => "#document".to_string(), - _ => "#other".to_string(), - }) - .collect() - } - - #[test] - fn test_simple_text() { - let doc = parse("hello"); - let html = doc.root_element().unwrap(); - assert_eq!(doc.node_name(html), Some("html")); - let children = child_names(&doc, html); - assert_eq!(children, vec!["head", "body"]); - let body = doc.children(html).nth(1).unwrap(); - assert_eq!(doc.text_content(body), "hello"); - } - - #[test] - fn test_basic_element() { - let doc = parse("<p>hi</p>"); - let html = doc.root_element().unwrap(); - assert_eq!(doc.node_name(html), Some("html")); - let body = doc.children(html).nth(1).unwrap(); - assert_eq!(doc.node_name(body), Some("body")); - let p = doc.first_child(body).unwrap(); - assert_eq!(doc.node_name(p), Some("p")); - assert_eq!(doc.text_content(p), "hi"); - } - - #[test] - fn test_implied_tags() { - let doc = parse("test"); - let html = doc.root_element().unwrap(); - assert_eq!(doc.node_name(html), Some("html")); - let children = child_names(&doc, html); - assert_eq!(children, vec!["head", "body"]); - } - - #[test] - fn test_nested_elements() { - let doc = parse("<div><p>text</p></div>"); - let html = doc.root_element().unwrap(); - let body = doc.children(html).nth(1).unwrap(); - let div = doc.first_child(body).unwrap(); - assert_eq!(doc.node_name(div), Some("div")); - let p = doc.first_child(div).unwrap(); - assert_eq!(doc.node_name(p), Some("p")); - assert_eq!(doc.text_content(p), "text"); - } - - #[test] - fn test_auto_closing_p() { - let doc = parse("<p>one<p>two"); - let html = doc.root_element().unwrap(); - let body = doc.children(html).nth(1).unwrap(); - let children = child_names(&doc, body); - assert_eq!(children, vec!["p", "p"]); - let p1 = doc.first_child(body).unwrap(); - let p2 = doc.next_sibling(p1).unwrap(); - assert_eq!(doc.text_content(p1), "one"); - assert_eq!(doc.text_content(p2), "two"); - } - - #[test] - fn test_formatting_elements() { - let doc = parse("<b>bold</b>normal"); - let html = doc.root_element().unwrap(); - let body = doc.children(html).nth(1).unwrap(); - let b = doc.first_child(body).unwrap(); - assert_eq!(doc.node_name(b), Some("b")); - assert_eq!(doc.text_content(b), "bold"); - let text = doc.next_sibling(b).unwrap(); - assert_eq!(doc.node_text(text), Some("normal")); - } - - #[test] - fn test_adoption_agency() { - // Classic misnesting: <b><i>bi</b>i</i> - // Expected: <b><i>bi</i></b><i>i</i> - let doc = parse("<b><i>bi</b>i</i>"); - let html = doc.root_element().unwrap(); - let body = doc.children(html).nth(1).unwrap(); - let body_children = child_names(&doc, body); - // Should have: b, i - assert_eq!(body_children.len(), 2); - let b_elem = doc.first_child(body).unwrap(); - assert_eq!(doc.node_name(b_elem), Some("b")); - let i_in_b = doc.first_child(b_elem).unwrap(); - assert_eq!(doc.node_name(i_in_b), Some("i")); - assert_eq!(doc.text_content(i_in_b), "bi"); - let i_after_b = doc.next_sibling(b_elem).unwrap(); - assert_eq!(doc.node_name(i_after_b), Some("i")); - assert_eq!(doc.text_content(i_after_b), "i"); - } - - #[test] - fn test_void_elements() { - let doc = parse("<br><img><hr>"); - let html = doc.root_element().unwrap(); - let body = doc.children(html).nth(1).unwrap(); - let children = child_names(&doc, body); - assert_eq!(children, vec!["br", "img", "hr"]); - // Void elements should have no children - let br = doc.first_child(body).unwrap(); - assert!(doc.first_child(br).is_none()); - } - - #[test] - fn test_table_structure() { - let doc = parse("<table><tr><td>cell</td></tr></table>"); - let html = doc.root_element().unwrap(); - let body = doc.children(html).nth(1).unwrap(); - let table = doc.first_child(body).unwrap(); - assert_eq!(doc.node_name(table), Some("table")); - let tbody = doc.first_child(table).unwrap(); - assert_eq!(doc.node_name(tbody), Some("tbody")); - let tr = doc.first_child(tbody).unwrap(); - assert_eq!(doc.node_name(tr), Some("tr")); - let td = doc.first_child(tr).unwrap(); - assert_eq!(doc.node_name(td), Some("td")); - assert_eq!(doc.text_content(td), "cell"); - } - - #[test] - fn test_doctype() { - let doc = parse("<!DOCTYPE html><html><body>hi</body></html>"); - let root = doc.root(); - // First child should be the doctype - let first = doc.first_child(root).unwrap(); - assert!(matches!( - doc.node(first).kind, - NodeKind::DocumentType { .. } - )); - let html = doc.root_element().unwrap(); - assert_eq!(doc.node_name(html), Some("html")); - let body = doc.children(html).nth(1).unwrap(); - assert_eq!(doc.text_content(body), "hi"); - } - - #[test] - fn test_comment() { - let doc = parse("<!-- comment --><p>text</p>"); - let html = doc.root_element().unwrap(); - let body = doc.children(html).nth(1).unwrap(); - let p = doc.first_child(body).unwrap(); - assert_eq!(doc.node_name(p), Some("p")); - assert_eq!(doc.text_content(p), "text"); - } - - #[test] - fn test_self_closing_svg() { - let doc = parse("<svg><circle/></svg>"); - let html = doc.root_element().unwrap(); - let body = doc.children(html).nth(1).unwrap(); - let svg = doc.first_child(body).unwrap(); - assert_eq!(doc.node_name(svg), Some("svg")); - } - - #[test] - fn test_template() { - let doc = parse("<template><p>content</p></template>"); - let html = doc.root_element().unwrap(); - let head = doc.first_child(html).unwrap(); - assert_eq!(doc.node_name(head), Some("head")); - let template = doc.first_child(head).unwrap(); - assert_eq!(doc.node_name(template), Some("template")); - } - - #[test] - fn test_select() { - let doc = parse("<select><option>a</option><option>b</option></select>"); - let html = doc.root_element().unwrap(); - let body = doc.children(html).nth(1).unwrap(); - let select = doc.first_child(body).unwrap(); - assert_eq!(doc.node_name(select), Some("select")); - let children = child_names(&doc, select); - assert_eq!(children, vec!["option", "option"]); - } -} diff --git a/browser/vendor/xmloxide/src/lib.rs b/browser/vendor/xmloxide/src/lib.rs index 0b0464010..9a0bb5fcd 100644 --- a/browser/vendor/xmloxide/src/lib.rs +++ b/browser/vendor/xmloxide/src/lib.rs @@ -6,23 +6,20 @@ //! //! ## Modules //! +//! This Scrapling-compatibility fork is trimmed to the slice the browser +//! worker consumes. Upstream's WHATWG html5 parser, CSS engine, SAX/reader +//! streaming APIs, RelaxNG/XSD/Schematron validators, XInclude, catalogs, +//! serde integration, async parsing, FFI layer, and xmllint CLI are removed; +//! restore them from upstream xmloxide if ever needed. +//! //! - [`tree`] — DOM tree representation with arena-allocated nodes ([`Document`], [`NodeId`]) //! - [`parser`] — XML 1.0 parser with error recovery and push/incremental parsing //! - [`html`] — Error-tolerant HTML 4.01 parser -//! - [`html5`] — WHATWG HTML5 parser (tokenizer + tree construction) -//! - [`html5::sax`] — Streaming SAX-like API for HTML5 (no DOM tree built) -//! - [`css`] — CSS selector engine for querying document trees -//! - [`sax`] — SAX2 event-driven streaming parser -//! - [`reader`] — `XmlReader` pull-based parsing API //! - [`xpath`] — `XPath` 1.0+ expression evaluation (includes key `XPath` 2.0 functions) -//! - [`validation`] — DTD, `RelaxNG`, XML Schema (XSD), and ISO Schematron validation +//! - [`validation`] — DTD processing (`validation::dtd`; the parser depends on it) //! - [`serial`] — XML/HTML serialization and Canonical XML (C14N) //! - [`encoding`] — Character encoding detection and conversion -//! - [`xinclude`] — `XInclude` 1.0 document inclusion -//! - [`catalog`] — OASIS XML Catalogs for URI resolution //! - [`error`] — Error types and diagnostics -//! - [`serde_xml`] — Serde XML (de)serialization (requires `serde` feature) -//! - [`async_xml`] — Async parsing via `tokio::io::AsyncRead` (requires `async` feature) //! //! ## Quick Start //! @@ -34,27 +31,15 @@ //! assert_eq!(doc.node_name(root), Some("root")); //! ``` -#[cfg(feature = "async")] -pub mod async_xml; -pub mod catalog; -pub mod css; pub mod encoding; pub mod error; -#[cfg(feature = "ffi")] -pub mod ffi; pub mod html; -pub mod html5; pub mod parser; -pub mod reader; -pub mod sax; -#[cfg(feature = "serde")] -pub mod serde_xml; pub mod serial; pub mod tree; #[allow(dead_code)] pub(crate) mod util; pub mod validation; -pub mod xinclude; pub mod xpath; // Re-export primary types at the crate root for convenience. diff --git a/browser/vendor/xmloxide/src/reader/mod.rs b/browser/vendor/xmloxide/src/reader/mod.rs deleted file mode 100644 index 2687e38a3..000000000 --- a/browser/vendor/xmloxide/src/reader/mod.rs +++ /dev/null @@ -1,1676 +0,0 @@ -//! Pull-based streaming XML reader API. -//! -//! The `XmlReader` provides a cursor-style, pull-based interface for reading -//! XML documents. Instead of building a full tree in memory or requiring -//! callback implementations (SAX), the reader advances one node at a time -//! through the document, exposing the current node's properties via accessor -//! methods. -//! -//! This API is similar to libxml2's `xmlTextReader` and .NET's `XmlReader`. -//! -//! # Usage Pattern -//! -//! Call [`XmlReader::read`] repeatedly to advance through the document. Each -//! call moves the cursor to the next node. Use accessor methods like -//! [`XmlReader::node_type`], [`XmlReader::name`], and [`XmlReader::value`] -//! to inspect the current node. When `read()` returns `Ok(false)`, the end -//! of the document has been reached. -//! -//! # Examples -//! -//! ``` -//! use xmloxide::reader::{XmlReader, XmlNodeType}; -//! -//! let mut reader = XmlReader::new("<root><child>Hello</child></root>"); -//! let mut elements = Vec::new(); -//! -//! while reader.read().unwrap() { -//! if reader.node_type() == XmlNodeType::Element { -//! elements.push(reader.name().unwrap_or_default().to_string()); -//! } -//! } -//! -//! assert_eq!(elements, vec!["root", "child"]); -//! ``` - -use crate::error::{ErrorSeverity, ParseDiagnostic, ParseError}; -use crate::parser::input::{ - parse_cdata_content, parse_comment_content, parse_pi_content, parse_xml_decl, split_name, - NamespaceResolver, ParserInput, -}; -use crate::parser::ParseOptions; - -/// The type of the current node in the reader. -/// -/// These correspond to the different kinds of nodes that the reader can -/// be positioned on while traversing an XML document. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum XmlNodeType { - /// No node — the reader has not been advanced yet or is in an - /// indeterminate state. - None, - - /// An element start tag, e.g. `<div>` or `<br/>`. - /// - /// For self-closing elements (`<br/>`), [`XmlReader::is_empty_element`] - /// returns `true`. - Element, - - /// An element end tag, e.g. `</div>`. - /// - /// Self-closing elements do not produce a separate `EndElement` node. - EndElement, - - /// A text node containing character data. - Text, - - /// A CDATA section, e.g. `<![CDATA[...]]>`. - CData, - - /// An XML comment, e.g. `<!-- comment -->`. - Comment, - - /// A processing instruction, e.g. `<?target data?>`. - ProcessingInstruction, - - /// The XML declaration, e.g. `<?xml version="1.0"?>`. - XmlDeclaration, - - /// A document type declaration, e.g. `<!DOCTYPE html>`. - DocumentType, - - /// A whitespace-only text node in element content. - Whitespace, - - /// An attribute node — the reader is positioned on an attribute after - /// calling [`XmlReader::move_to_first_attribute`] or - /// [`XmlReader::move_to_next_attribute`]. - Attribute, - - /// The end of the document has been reached. - EndDocument, -} - -impl std::fmt::Display for XmlNodeType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::None => write!(f, "None"), - Self::Element => write!(f, "Element"), - Self::EndElement => write!(f, "EndElement"), - Self::Text => write!(f, "Text"), - Self::CData => write!(f, "CData"), - Self::Comment => write!(f, "Comment"), - Self::ProcessingInstruction => write!(f, "ProcessingInstruction"), - Self::XmlDeclaration => write!(f, "XmlDeclaration"), - Self::DocumentType => write!(f, "DocumentType"), - Self::Whitespace => write!(f, "Whitespace"), - Self::Attribute => write!(f, "Attribute"), - Self::EndDocument => write!(f, "EndDocument"), - } - } -} - -/// An attribute on the current element in the reader. -#[derive(Debug, Clone)] -struct ReaderAttribute { - /// The local name of the attribute. - local_name: String, - /// The attribute value. - value: String, - /// The namespace prefix, if any. - prefix: Option<String>, - /// The namespace URI, if any. - namespace_uri: Option<String>, -} - -/// Internal representation of a node the reader is positioned on. -#[derive(Debug, Clone)] -struct ReaderNode { - /// The type of this node. - node_type: XmlNodeType, - /// The local name (for elements, PIs) or the full name/target. - local_name: String, - /// The namespace prefix, if any. - prefix: Option<String>, - /// The namespace URI, if any. - namespace_uri: Option<String>, - /// The value/content (for text, comment, CDATA, PI data, attribute value). - value: Option<String>, - /// The depth of this node in the document tree. - depth: u32, - /// Whether this is an empty (self-closing) element. - is_empty_element: bool, - /// Attributes of the current element (empty for non-elements). - attributes: Vec<ReaderAttribute>, -} - -impl ReaderNode { - fn new(node_type: XmlNodeType) -> Self { - Self { - node_type, - local_name: String::new(), - prefix: None, - namespace_uri: None, - value: None, - depth: 0, - is_empty_element: false, - attributes: Vec::new(), - } - } -} - -/// A pull-based streaming XML reader. -/// -/// The reader parses an XML document incrementally, advancing one node at -/// a time. This is memory-efficient for large documents because it does not -/// build a full tree. -/// -/// # Examples -/// -/// ``` -/// use xmloxide::reader::{XmlReader, XmlNodeType}; -/// -/// let mut reader = XmlReader::new("<doc attr=\"val\">text</doc>"); -/// -/// // Advance to <doc> -/// assert!(reader.read().unwrap()); -/// assert_eq!(reader.node_type(), XmlNodeType::Element); -/// assert_eq!(reader.name(), Some("doc")); -/// assert_eq!(reader.depth(), 0); -/// assert_eq!(reader.attribute_count(), 1); -/// assert_eq!(reader.get_attribute("attr"), Some("val")); -/// -/// // Advance to text content -/// assert!(reader.read().unwrap()); -/// assert_eq!(reader.node_type(), XmlNodeType::Text); -/// assert_eq!(reader.value(), Some("text")); -/// -/// // Advance to </doc> -/// assert!(reader.read().unwrap()); -/// assert_eq!(reader.node_type(), XmlNodeType::EndElement); -/// -/// // End of document -/// assert!(!reader.read().unwrap()); -/// ``` -#[allow(clippy::struct_excessive_bools)] -pub struct XmlReader<'a> { - /// Shared low-level input state (position, peek, advance, name parsing, etc.). - parser_input: ParserInput<'a>, - /// Parser options. - options: ParseOptions, - /// Namespace resolver managing the scope stack. - ns: NamespaceResolver, - /// The current node the reader is positioned on. - current: ReaderNode, - /// Queued nodes to emit before parsing more input. - /// For example, an element produces `Element` + `EndElement` for self-closing tags. - queue: Vec<ReaderNode>, - /// The current depth in the element tree. - depth: u32, - /// Whether parsing has started (first `read()` has been called). - started: bool, - /// Whether the document has ended. - finished: bool, - /// Whether we have parsed the prolog (xml decl, doctype, misc). - prolog_parsed: bool, - /// Whether the root element has been parsed. - root_parsed: bool, - /// Whether we are inside the root element content. - in_element_content: bool, - /// Stack of open element names (for matching end tags). - element_stack: Vec<String>, - /// Current attribute index when iterating over attributes. - attribute_index: Option<usize>, - /// The element node saved when navigating attributes. - saved_element: Option<ReaderNode>, -} - -impl<'a> XmlReader<'a> { - /// Creates a new `XmlReader` from a string slice with default options. - /// - /// # Examples - /// - /// ``` - /// use xmloxide::reader::XmlReader; - /// - /// let mut reader = XmlReader::new("<root/>"); - /// assert!(reader.read().unwrap()); - /// ``` - #[must_use] - pub fn new(input: &'a str) -> Self { - Self::with_options(input, ParseOptions::default()) - } - - /// Creates a new `XmlReader` from a string slice with custom parse options. - /// - /// # Examples - /// - /// ``` - /// use xmloxide::reader::XmlReader; - /// use xmloxide::parser::ParseOptions; - /// - /// let opts = ParseOptions::default().recover(true); - /// let mut reader = XmlReader::with_options("<root/>", opts); - /// assert!(reader.read().unwrap()); - /// ``` - #[must_use] - pub fn with_options(input: &'a str, options: ParseOptions) -> Self { - let mut pi = ParserInput::new(input); - pi.set_recover(options.recover); - pi.set_max_depth(options.max_depth); - pi.set_max_name_length(options.max_name_length); - pi.set_max_entity_expansions(options.max_entity_expansions); - pi.set_entity_resolver(options.entity_resolver.clone()); - - Self { - parser_input: pi, - options, - ns: NamespaceResolver::new(), - current: ReaderNode::new(XmlNodeType::None), - queue: Vec::new(), - depth: 0, - started: false, - finished: false, - prolog_parsed: false, - root_parsed: false, - in_element_content: false, - element_stack: Vec::new(), - attribute_index: None, - saved_element: None, - } - } - - // === Public API: reading === - - /// Advances the reader to the next node in the document. - /// - /// Returns `Ok(true)` if the reader successfully advanced to a node, - /// or `Ok(false)` if the end of the document has been reached. - /// - /// # Errors - /// - /// Returns `ParseError` if the XML is malformed and recovery mode is - /// not enabled. - /// - /// # Examples - /// - /// ``` - /// use xmloxide::reader::XmlReader; - /// - /// let mut reader = XmlReader::new("<root/>"); - /// while reader.read().unwrap() { - /// // process each node - /// } - /// ``` - pub fn read(&mut self) -> Result<bool, ParseError> { - // Reset attribute navigation state when advancing. - self.attribute_index = None; - self.saved_element = None; - - if self.finished { - return Ok(false); - } - - // Drain queued nodes first. - if let Some(node) = self.queue.pop() { - self.current = node; - return Ok(true); - } - - if !self.started { - self.started = true; - } - - self.read_next_node() - } - - // === Public API: node type and properties === - - /// Returns the type of the current node. - /// - /// Before the first call to [`read`](Self::read), returns - /// [`XmlNodeType::None`]. - #[must_use] - pub fn node_type(&self) -> XmlNodeType { - self.current.node_type - } - - /// Returns the qualified name of the current node. - /// - /// For elements, this returns `prefix:localname` if a prefix is present, - /// or just `localname` otherwise. For processing instructions, this is - /// the target. For `DocumentType` nodes, this is the root element name. - /// For other node types, returns `None`. - #[must_use] - pub fn name(&self) -> Option<&str> { - match self.current.node_type { - XmlNodeType::Element - | XmlNodeType::EndElement - | XmlNodeType::ProcessingInstruction - | XmlNodeType::Attribute - | XmlNodeType::DocumentType => { - if self.current.local_name.is_empty() { - None - } else { - Some(&self.current.local_name) - } - } - _ => None, - } - } - - /// Returns the local name of the current node (without namespace prefix). - /// - /// For elements and attributes, this is the local part of the qualified - /// name. For processing instructions, this is the target. - #[must_use] - pub fn local_name(&self) -> Option<&str> { - match self.current.node_type { - XmlNodeType::Element - | XmlNodeType::EndElement - | XmlNodeType::ProcessingInstruction - | XmlNodeType::Attribute - | XmlNodeType::DocumentType => { - if self.current.local_name.is_empty() { - None - } else { - Some(&self.current.local_name) - } - } - _ => None, - } - } - - /// Returns the namespace prefix of the current node, if any. - /// - /// For elements and attributes with a prefix (e.g., `svg` in `<svg:rect>`), - /// returns the prefix string. For unprefixed elements, processing - /// instructions, text, comments, and other node types, returns `None`. - #[must_use] - pub fn prefix(&self) -> Option<&str> { - self.current.prefix.as_deref() - } - - /// Returns the namespace URI of the current node, if any. - /// - /// Namespace URIs are resolved for elements and attributes that are in a - /// namespace (either via a prefix or a default namespace declaration). - /// Returns `None` for nodes that have no namespace or for node types that - /// do not carry namespace information (text, comments, etc.). - #[must_use] - pub fn namespace_uri(&self) -> Option<&str> { - self.current.namespace_uri.as_deref() - } - - /// Returns the value of the current node, if applicable. - /// - /// For text, CDATA, comment, whitespace, and attribute nodes, this is - /// the text content. For processing instructions, this is the data - /// portion. For elements and end elements, returns `None`. - #[must_use] - pub fn value(&self) -> Option<&str> { - self.current.value.as_deref() - } - - /// Returns whether the current node has a value. - /// - /// Returns `true` for node types that carry text content: text, CDATA, - /// comment, whitespace, attribute, and processing instruction nodes. - /// Returns `false` for elements, end elements, and the document type. - #[must_use] - pub fn has_value(&self) -> bool { - self.current.value.is_some() - } - - /// Returns whether the current element is a self-closing (empty) element. - /// - /// Returns `true` for elements like `<br/>`, `false` for elements - /// like `<div>...</div>`. Always returns `false` for non-element nodes. - #[must_use] - pub fn is_empty_element(&self) -> bool { - self.current.is_empty_element - } - - /// Returns the depth of the current node in the document tree. - /// - /// The root element is at depth 0, its children at depth 1, and so on. - /// Nodes in the prolog (XML declaration, DOCTYPE) are at depth 0. - #[must_use] - pub fn depth(&self) -> u32 { - self.current.depth - } - - /// Returns the number of attributes on the current element. - /// - /// Returns 0 for non-element nodes. - #[must_use] - pub fn attribute_count(&self) -> usize { - self.current.attributes.len() - } - - /// Returns the value of an attribute by name on the current element. - /// - /// Searches by the full attribute name (qualified name). Returns `None` - /// if the attribute is not present or the current node is not an element. - /// - /// # Examples - /// - /// ``` - /// use xmloxide::reader::{XmlReader, XmlNodeType}; - /// - /// let mut reader = XmlReader::new("<root id=\"42\"/>"); - /// reader.read().unwrap(); - /// assert_eq!(reader.get_attribute("id"), Some("42")); - /// assert_eq!(reader.get_attribute("missing"), None); - /// ``` - #[must_use] - pub fn get_attribute(&self, name: &str) -> Option<&str> { - let attrs = &self.current.attributes; - for attr in attrs { - let full_name = match &attr.prefix { - Some(pfx) => { - // Compare against "prefix:local_name" - if name.starts_with(pfx.as_str()) - && name.as_bytes().get(pfx.len()) == Some(&b':') - && name[pfx.len() + 1..] == *attr.local_name - { - return Some(&attr.value); - } - continue; - } - None => &attr.local_name, - }; - if full_name == name { - return Some(&attr.value); - } - } - None - } - - /// Returns the value of an attribute by local name and namespace URI. - /// - /// Returns `None` if the attribute is not present, the namespace does - /// not match, or the current node is not an element. - #[must_use] - pub fn get_attribute_ns(&self, local_name: &str, namespace_uri: &str) -> Option<&str> { - self.current.attributes.iter().find_map(|attr| { - if attr.local_name == local_name && attr.namespace_uri.as_deref() == Some(namespace_uri) - { - Some(attr.value.as_str()) - } else { - None - } - }) - } - - // === Public API: attribute navigation === - - /// Moves the reader to the first attribute of the current element. - /// - /// Returns `true` if the element has attributes and the reader was - /// moved to the first one. Returns `false` if there are no attributes - /// or the current node is not an element. - /// - /// # Examples - /// - /// ``` - /// use xmloxide::reader::{XmlReader, XmlNodeType}; - /// - /// let mut reader = XmlReader::new("<root a=\"1\" b=\"2\"/>"); - /// reader.read().unwrap(); - /// - /// assert!(reader.move_to_first_attribute()); - /// assert_eq!(reader.node_type(), XmlNodeType::Attribute); - /// assert_eq!(reader.name(), Some("a")); - /// assert_eq!(reader.value(), Some("1")); - /// ``` - pub fn move_to_first_attribute(&mut self) -> bool { - if self.current.node_type != XmlNodeType::Element - && self.current.node_type != XmlNodeType::Attribute - { - return false; - } - - // Save the element node if we haven't already. - if self.saved_element.is_none() { - if self.current.node_type == XmlNodeType::Attribute { - // Already navigating attributes; don't overwrite saved. - } else { - self.saved_element = Some(self.current.clone()); - } - } - - let elem = self.saved_element.as_ref().unwrap_or(&self.current); - - if elem.attributes.is_empty() { - return false; - } - - let attr = &elem.attributes[0]; - self.current = ReaderNode { - node_type: XmlNodeType::Attribute, - local_name: attr.local_name.clone(), - prefix: attr.prefix.clone(), - namespace_uri: attr.namespace_uri.clone(), - value: Some(attr.value.clone()), - depth: elem.depth + 1, - is_empty_element: false, - attributes: elem.attributes.clone(), - }; - self.attribute_index = Some(0); - true - } - - /// Moves the reader to the next attribute of the current element. - /// - /// Returns `true` if there is a next attribute. Returns `false` if - /// there are no more attributes or the reader is not on an attribute. - /// - /// # Examples - /// - /// ``` - /// use xmloxide::reader::{XmlReader, XmlNodeType}; - /// - /// let mut reader = XmlReader::new("<root a=\"1\" b=\"2\"/>"); - /// reader.read().unwrap(); - /// - /// assert!(reader.move_to_first_attribute()); - /// assert_eq!(reader.name(), Some("a")); - /// - /// assert!(reader.move_to_next_attribute()); - /// assert_eq!(reader.name(), Some("b")); - /// - /// assert!(!reader.move_to_next_attribute()); - /// ``` - pub fn move_to_next_attribute(&mut self) -> bool { - let Some(idx) = self.attribute_index else { - // If not currently on an attribute, try to start from the first. - return self.move_to_first_attribute(); - }; - - let elem = self.saved_element.as_ref().unwrap_or(&self.current); - - let next_idx = idx + 1; - if next_idx >= elem.attributes.len() { - return false; - } - - let attr = &elem.attributes[next_idx]; - self.current = ReaderNode { - node_type: XmlNodeType::Attribute, - local_name: attr.local_name.clone(), - prefix: attr.prefix.clone(), - namespace_uri: attr.namespace_uri.clone(), - value: Some(attr.value.clone()), - depth: elem.depth + 1, - is_empty_element: false, - attributes: elem.attributes.clone(), - }; - self.attribute_index = Some(next_idx); - true - } - - /// Moves the reader back to the element that owns the current attribute. - /// - /// Returns `true` if the reader was on an attribute and was moved back - /// to the element. Returns `false` if the reader was not on an attribute. - /// - /// # Examples - /// - /// ``` - /// use xmloxide::reader::{XmlReader, XmlNodeType}; - /// - /// let mut reader = XmlReader::new("<root a=\"1\"/>"); - /// reader.read().unwrap(); - /// reader.move_to_first_attribute(); - /// assert_eq!(reader.node_type(), XmlNodeType::Attribute); - /// - /// assert!(reader.move_to_element()); - /// assert_eq!(reader.node_type(), XmlNodeType::Element); - /// assert_eq!(reader.name(), Some("root")); - /// ``` - pub fn move_to_element(&mut self) -> bool { - if let Some(elem) = self.saved_element.take() { - self.current = elem; - self.attribute_index = None; - true - } else { - false - } - } - - /// Returns the diagnostics collected during parsing. - /// - /// In recovery mode, this includes warnings and errors that were - /// encountered but did not halt parsing. - #[must_use] - pub fn diagnostics(&self) -> &[ParseDiagnostic] { - &self.parser_input.diagnostics - } - - // === Internal: top-level node dispatch === - - fn read_next_node(&mut self) -> Result<bool, ParseError> { - // Parse prolog if not done yet. - if !self.prolog_parsed { - return self.read_prolog(); - } - - // Parse root element and content. - if !self.root_parsed { - return self.read_root_or_prolog_misc(); - } - - // If inside element content, parse child nodes. - if self.in_element_content { - return self.read_element_content(); - } - - // After root element, parse trailing misc. - self.read_trailing_misc() - } - - fn read_prolog(&mut self) -> Result<bool, ParseError> { - self.parser_input.skip_whitespace(); - - // Parse XML declaration if present. - if self.parser_input.looking_at(b"<?xml ") - || self.parser_input.looking_at(b"<?xml\t") - || self.parser_input.looking_at(b"<?xml\r") - || self.parser_input.looking_at(b"<?xml?>") - { - let node = self.parse_xml_declaration()?; - self.current = node; - // Don't set prolog_parsed yet; there may be misc nodes. - return Ok(true); - } - - self.prolog_parsed = true; - self.read_root_or_prolog_misc() - } - - fn read_root_or_prolog_misc(&mut self) -> Result<bool, ParseError> { - self.parser_input.skip_whitespace(); - - if self.parser_input.at_end() { - self.finished = true; - self.current = ReaderNode::new(XmlNodeType::EndDocument); - return Ok(false); - } - - // DOCTYPE - if self.parser_input.looking_at(b"<!DOCTYPE") || self.parser_input.looking_at(b"<!doctype") - { - let node = self.parse_doctype()?; - self.current = node; - return Ok(true); - } - - // Comment - if self.parser_input.looking_at(b"<!--") { - let node = self.parse_comment()?; - self.current = node; - return Ok(true); - } - - // Processing instruction - if self.parser_input.looking_at(b"<?") { - let node = self.parse_processing_instruction()?; - self.current = node; - return Ok(true); - } - - // Root element start. - if self.parser_input.peek() == Some(b'<') - && self - .parser_input - .peek_at(1) - .is_some_and(|b| b != b'!' && b != b'?') - { - self.root_parsed = true; - let node = self.parse_element_start()?; - self.current = node; - return Ok(true); - } - - if !self.parser_input.at_end() && !self.options.recover { - return Err(self.parser_input.fatal("expected root element")); - } - - self.finished = true; - self.current = ReaderNode::new(XmlNodeType::EndDocument); - Ok(false) - } - - fn read_element_content(&mut self) -> Result<bool, ParseError> { - if self.parser_input.at_end() { - if self.options.recover { - // Force-close all open elements. - if let Some(name) = self.element_stack.pop() { - self.depth -= 1; - self.parser_input.decrement_depth(); - self.ns.pop_scope(); - let mut node = ReaderNode::new(XmlNodeType::EndElement); - let (prefix, local_name) = split_name(&name); - node.local_name = local_name.to_string(); - node.prefix = prefix.map(String::from); - node.depth = self.depth; - self.in_element_content = !self.element_stack.is_empty(); - self.current = node; - return Ok(true); - } - self.finished = true; - self.current = ReaderNode::new(XmlNodeType::EndDocument); - return Ok(false); - } - return Err(self - .parser_input - .fatal("unexpected end of input in element content")); - } - - // End tag. - if self.parser_input.looking_at(b"</") { - let node = self.parse_end_tag()?; - self.current = node; - return Ok(true); - } - - // CDATA section. - if self.parser_input.looking_at(b"<![CDATA[") { - let node = self.parse_cdata()?; - self.current = node; - return Ok(true); - } - - // Comment. - if self.parser_input.looking_at(b"<!--") { - let node = self.parse_comment()?; - self.current = node; - return Ok(true); - } - - // Processing instruction. - if self.parser_input.looking_at(b"<?") { - let node = self.parse_processing_instruction()?; - self.current = node; - return Ok(true); - } - - // Child element. - if self.parser_input.peek() == Some(b'<') - && self - .parser_input - .peek_at(1) - .is_some_and(|b| b != b'!' && b != b'?') - { - let node = self.parse_element_start()?; - self.current = node; - return Ok(true); - } - - // Character data (text). - let node = self.parse_char_data()?; - - // Skip whitespace-only text nodes if no_blanks is enabled. - if self.options.no_blanks && node.node_type == XmlNodeType::Whitespace { - return self.read_element_content(); - } - - self.current = node; - Ok(true) - } - - fn read_trailing_misc(&mut self) -> Result<bool, ParseError> { - self.parser_input.skip_whitespace(); - - if self.parser_input.at_end() { - self.finished = true; - self.current = ReaderNode::new(XmlNodeType::EndDocument); - return Ok(false); - } - - // Comment. - if self.parser_input.looking_at(b"<!--") { - let node = self.parse_comment()?; - self.current = node; - return Ok(true); - } - - // Processing instruction. - if self.parser_input.looking_at(b"<?") { - let node = self.parse_processing_instruction()?; - self.current = node; - return Ok(true); - } - - if !self.options.recover { - return Err(self.parser_input.fatal("content after document element")); - } - - self.finished = true; - self.current = ReaderNode::new(XmlNodeType::EndDocument); - Ok(false) - } - - // === Internal: parse individual constructs === - - fn parse_xml_declaration(&mut self) -> Result<ReaderNode, ParseError> { - let decl = parse_xml_decl(&mut self.parser_input)?; - self.prolog_parsed = true; - - // Build the value string as "version=X encoding=Y standalone=Z". - let mut value_parts = vec![format!("version={}", decl.version)]; - if let Some(ref enc) = decl.encoding { - value_parts.push(format!("encoding={enc}")); - } - if let Some(sa) = decl.standalone { - let sa_str = if sa { "yes" } else { "no" }; - value_parts.push(format!("standalone={sa_str}")); - } - - let mut node = ReaderNode::new(XmlNodeType::XmlDeclaration); - node.local_name = "xml".to_string(); - node.value = Some(value_parts.join(" ")); - node.depth = 0; - Ok(node) - } - - fn parse_doctype(&mut self) -> Result<ReaderNode, ParseError> { - // Parse: <!DOCTYPE name (SYSTEM|PUBLIC ...) [internal subset]? > - self.parser_input.expect_str(b"<!DOCTYPE")?; - self.parser_input.skip_whitespace_required()?; - let name = self.parser_input.parse_name()?; - self.parser_input.skip_whitespace(); - - if self.parser_input.looking_at(b"SYSTEM") { - self.parser_input.expect_str(b"SYSTEM")?; - self.parser_input.skip_whitespace_required()?; - self.parser_input.parse_quoted_value()?; - self.parser_input.skip_whitespace(); - } else if self.parser_input.looking_at(b"PUBLIC") { - self.parser_input.expect_str(b"PUBLIC")?; - self.parser_input.skip_whitespace_required()?; - self.parser_input.parse_quoted_value()?; - self.parser_input.skip_whitespace_required()?; - self.parser_input.parse_quoted_value()?; - self.parser_input.skip_whitespace(); - } - - if self.parser_input.peek() == Some(b'[') { - self.parser_input.advance(1); - let start = self.parser_input.pos(); - let mut bracket_depth: u32 = 1; - while !self.parser_input.at_end() && bracket_depth > 0 { - if self.parser_input.looking_at(b"<!--") { - self.parser_input.advance(4); - while !self.parser_input.at_end() && !self.parser_input.looking_at(b"-->") { - self.parser_input.advance(1); - } - if !self.parser_input.at_end() { - self.parser_input.advance(3); - } - } else if let Some(b'"' | b'\'') = self.parser_input.peek() { - let quote = self.parser_input.peek().unwrap_or(b'"'); - self.parser_input.advance(1); - while !self.parser_input.at_end() && self.parser_input.peek() != Some(quote) { - self.parser_input.advance(1); - } - if !self.parser_input.at_end() { - self.parser_input.advance(1); - } - } else if self.parser_input.peek() == Some(b'[') { - bracket_depth += 1; - self.parser_input.advance(1); - } else if self.parser_input.peek() == Some(b']') { - bracket_depth -= 1; - self.parser_input.advance(1); - } else { - self.parser_input.advance(1); - } - } - - // Parse DTD internal subset for entity declarations - let end = self.parser_input.pos() - 1; - let subset_text = std::str::from_utf8(self.parser_input.slice(start, end)) - .ok() - .map(str::to_string); - if let Some(subset_text) = subset_text { - if subset_text.contains('%') { - self.parser_input.has_pe_references = true; - } - if let Ok(dtd) = crate::validation::dtd::parse_dtd(&subset_text) { - for (ent_name, ent_decl) in &dtd.entities { - match &ent_decl.kind { - crate::validation::dtd::EntityKind::Internal(value) => { - self.parser_input - .entity_map - .insert(ent_name.clone(), value.clone()); - } - crate::validation::dtd::EntityKind::External { - system_id, - public_id, - } => { - self.parser_input.entity_external.insert( - ent_name.clone(), - crate::parser::input::ExternalEntityInfo { - system_id: system_id.clone(), - public_id: public_id.clone(), - }, - ); - } - } - } - } - } - - self.parser_input.skip_whitespace(); - } - - self.parser_input.expect_byte(b'>')?; - - let mut node = ReaderNode::new(XmlNodeType::DocumentType); - node.local_name = name; - node.depth = 0; - Ok(node) - } - - fn parse_element_start(&mut self) -> Result<ReaderNode, ParseError> { - self.parser_input.increment_depth()?; - self.parser_input.expect_byte(b'<')?; - let name = self.parser_input.parse_name()?; - - // Parse attributes. - let mut raw_attrs: Vec<(String, String)> = Vec::new(); - loop { - let had_ws = self.parser_input.skip_whitespace(); - if self.parser_input.peek() == Some(b'>') || self.parser_input.looking_at(b"/>") { - break; - } - if !had_ws { - return Err(self - .parser_input - .fatal("whitespace required between attributes")); - } - let attr_name = self.parser_input.parse_name()?; - self.parser_input.skip_whitespace(); - self.parser_input.expect_byte(b'=')?; - self.parser_input.skip_whitespace(); - let attr_value = self.parser_input.parse_attribute_value()?; - raw_attrs.push((attr_name, attr_value)); - } - - // Namespace processing. - self.ns.push_scope(); - for (attr_name, attr_value) in &raw_attrs { - if attr_name == "xmlns" { - self.ns.bind(None, attr_value.clone()); - } else if let Some(prefix) = attr_name.strip_prefix("xmlns:") { - self.ns.bind(Some(prefix.to_string()), attr_value.clone()); - } - } - - // Resolve element namespace. - let (prefix, local_name) = split_name(&name); - let elem_ns = self.ns.resolve(prefix).map(String::from); - - // Build attribute list. - let attributes: Vec<ReaderAttribute> = raw_attrs - .iter() - .map(|(attr_name, attr_value)| { - let (attr_prefix, attr_local) = split_name(attr_name); - let attr_ns = if attr_prefix == Some("xmlns") - || (attr_prefix.is_none() && attr_local == "xmlns") - { - None - } else { - attr_prefix - .and_then(|p| self.ns.resolve(Some(p))) - .map(String::from) - }; - ReaderAttribute { - local_name: attr_local.to_string(), - value: attr_value.clone(), - prefix: attr_prefix.map(String::from), - namespace_uri: attr_ns, - } - }) - .collect(); - - let is_empty = self.parser_input.looking_at(b"/>"); - if is_empty { - self.parser_input.advance(2); - } else { - self.parser_input.expect_byte(b'>')?; - } - - let current_depth = self.depth; - - let mut node = ReaderNode::new(XmlNodeType::Element); - node.local_name = local_name.to_string(); - node.prefix = prefix.map(String::from); - node.namespace_uri = elem_ns; - node.depth = current_depth; - node.is_empty_element = is_empty; - node.attributes = attributes; - - if is_empty { - // For empty elements, by convention in .NET-style readers, we - // do NOT emit a separate EndElement. The is_empty_element flag - // signals the caller. We do however need to pop the ns scope - // and decrement the security depth counter. - self.ns.pop_scope(); - self.parser_input.decrement_depth(); - } else { - self.element_stack.push(name); - self.depth += 1; - self.in_element_content = true; - } - - Ok(node) - } - - fn parse_end_tag(&mut self) -> Result<ReaderNode, ParseError> { - self.parser_input.expect_str(b"</")?; - let name = self.parser_input.parse_name()?; - self.parser_input.skip_whitespace(); - self.parser_input.expect_byte(b'>')?; - - // Match against the open element stack. - if let Some(expected) = self.element_stack.last() { - if *expected != name { - if self.options.recover { - self.parser_input.push_diagnostic( - ErrorSeverity::Error, - format!("mismatched end tag: expected </{expected}>, found </{name}>"), - ); - } else { - return Err(self.parser_input.fatal(format!( - "mismatched end tag: expected </{expected}>, found </{name}>" - ))); - } - } - } - - self.element_stack.pop(); - self.depth -= 1; - self.parser_input.decrement_depth(); - self.ns.pop_scope(); - self.in_element_content = !self.element_stack.is_empty(); - - let (prefix, local_name) = split_name(&name); - let mut node = ReaderNode::new(XmlNodeType::EndElement); - node.local_name = local_name.to_string(); - node.prefix = prefix.map(String::from); - node.depth = self.depth; - - Ok(node) - } - - fn parse_char_data(&mut self) -> Result<ReaderNode, ParseError> { - let mut text = String::new(); - while !self.parser_input.at_end() { - if self.parser_input.peek() == Some(b'<') { - break; - } - - // XML 1.0 §2.4: "]]>" is forbidden in character data - if self.parser_input.looking_at(b"]]>") { - if self.options.recover { - self.parser_input.push_diagnostic( - ErrorSeverity::Error, - "']]>' not allowed in character data".to_string(), - ); - text.push_str("]]>"); - self.parser_input.advance(3); - continue; - } - return Err(self - .parser_input - .fatal("']]>' not allowed in character data")); - } - - if self.parser_input.peek() == Some(b'&') { - self.parser_input.parse_reference_into(&mut text)?; - } else { - let ch = self.parser_input.next_char()?; - text.push(ch); - } - } - - let is_whitespace = text - .chars() - .all(|c| c == ' ' || c == '\t' || c == '\n' || c == '\r'); - - let node_type = if is_whitespace { - XmlNodeType::Whitespace - } else { - XmlNodeType::Text - }; - - let mut node = ReaderNode::new(node_type); - node.value = Some(text); - node.depth = self.depth; - Ok(node) - } - - fn parse_comment(&mut self) -> Result<ReaderNode, ParseError> { - let content = parse_comment_content(&mut self.parser_input)?; - let mut node = ReaderNode::new(XmlNodeType::Comment); - node.value = Some(content); - node.depth = self.depth; - Ok(node) - } - - fn parse_cdata(&mut self) -> Result<ReaderNode, ParseError> { - let content = parse_cdata_content(&mut self.parser_input)?; - let mut node = ReaderNode::new(XmlNodeType::CData); - node.value = Some(content); - node.depth = self.depth; - Ok(node) - } - - fn parse_processing_instruction(&mut self) -> Result<ReaderNode, ParseError> { - let (target, data) = parse_pi_content(&mut self.parser_input)?; - let mut node = ReaderNode::new(XmlNodeType::ProcessingInstruction); - node.local_name = target; - node.value = data; - node.depth = self.depth; - Ok(node) - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - - // --- Helper --- - - fn read_all_types(input: &str) -> Vec<(XmlNodeType, String)> { - let mut reader = XmlReader::new(input); - let mut result = Vec::new(); - while reader.read().unwrap() { - let label = match reader.node_type() { - XmlNodeType::Element | XmlNodeType::EndElement => { - reader.name().unwrap_or("").to_string() - } - XmlNodeType::Text - | XmlNodeType::CData - | XmlNodeType::Comment - | XmlNodeType::Whitespace - | XmlNodeType::XmlDeclaration => reader.value().unwrap_or("").to_string(), - XmlNodeType::ProcessingInstruction => { - let target = reader.name().unwrap_or("").to_string(); - match reader.value() { - Some(data) => format!("{target} {data}"), - None => target, - } - } - XmlNodeType::DocumentType => reader.name().unwrap_or("").to_string(), - _ => String::new(), - }; - result.push((reader.node_type(), label)); - } - result - } - - // === Test: basic element === - - #[test] - fn test_read_empty_element() { - let mut reader = XmlReader::new("<root/>"); - assert!(reader.read().unwrap()); - assert_eq!(reader.node_type(), XmlNodeType::Element); - assert_eq!(reader.name(), Some("root")); - assert!(reader.is_empty_element()); - assert_eq!(reader.depth(), 0); - - // No EndElement for empty elements. - assert!(!reader.read().unwrap()); - } - - #[test] - fn test_read_element_with_content() { - let nodes = read_all_types("<root>Hello</root>"); - assert_eq!( - nodes, - vec![ - (XmlNodeType::Element, "root".to_string()), - (XmlNodeType::Text, "Hello".to_string()), - (XmlNodeType::EndElement, "root".to_string()), - ] - ); - } - - #[test] - fn test_read_nested_elements() { - let nodes = read_all_types("<a><b>text</b></a>"); - assert_eq!( - nodes, - vec![ - (XmlNodeType::Element, "a".to_string()), - (XmlNodeType::Element, "b".to_string()), - (XmlNodeType::Text, "text".to_string()), - (XmlNodeType::EndElement, "b".to_string()), - (XmlNodeType::EndElement, "a".to_string()), - ] - ); - } - - // === Test: depth tracking === - - #[test] - fn test_read_depth_tracking() { - let mut reader = XmlReader::new("<a><b><c/></b></a>"); - - reader.read().unwrap(); // <a> - assert_eq!(reader.depth(), 0); - assert_eq!(reader.name(), Some("a")); - - reader.read().unwrap(); // <b> - assert_eq!(reader.depth(), 1); - assert_eq!(reader.name(), Some("b")); - - reader.read().unwrap(); // <c/> - assert_eq!(reader.depth(), 2); - assert_eq!(reader.name(), Some("c")); - assert!(reader.is_empty_element()); - - reader.read().unwrap(); // </b> - assert_eq!(reader.depth(), 1); - assert_eq!(reader.node_type(), XmlNodeType::EndElement); - - reader.read().unwrap(); // </a> - assert_eq!(reader.depth(), 0); - assert_eq!(reader.node_type(), XmlNodeType::EndElement); - - assert!(!reader.read().unwrap()); // EOF - } - - // === Test: attributes === - - #[test] - fn test_read_attributes() { - let mut reader = XmlReader::new("<root id=\"1\" class=\"big\"/>"); - reader.read().unwrap(); - - assert_eq!(reader.attribute_count(), 2); - assert_eq!(reader.get_attribute("id"), Some("1")); - assert_eq!(reader.get_attribute("class"), Some("big")); - assert_eq!(reader.get_attribute("missing"), None); - } - - #[test] - fn test_attribute_navigation() { - let mut reader = XmlReader::new("<root a=\"1\" b=\"2\" c=\"3\"/>"); - reader.read().unwrap(); - assert_eq!(reader.node_type(), XmlNodeType::Element); - - // Move to first attribute. - assert!(reader.move_to_first_attribute()); - assert_eq!(reader.node_type(), XmlNodeType::Attribute); - assert_eq!(reader.name(), Some("a")); - assert_eq!(reader.value(), Some("1")); - - // Move to second attribute. - assert!(reader.move_to_next_attribute()); - assert_eq!(reader.name(), Some("b")); - assert_eq!(reader.value(), Some("2")); - - // Move to third attribute. - assert!(reader.move_to_next_attribute()); - assert_eq!(reader.name(), Some("c")); - assert_eq!(reader.value(), Some("3")); - - // No more attributes. - assert!(!reader.move_to_next_attribute()); - - // Move back to element. - assert!(reader.move_to_element()); - assert_eq!(reader.node_type(), XmlNodeType::Element); - assert_eq!(reader.name(), Some("root")); - } - - // === Test: text and whitespace === - - #[test] - fn test_read_text_content() { - let mut reader = XmlReader::new("<p>Hello &amp; world</p>"); - reader.read().unwrap(); // <p> - reader.read().unwrap(); // text - assert_eq!(reader.node_type(), XmlNodeType::Text); - assert_eq!(reader.value(), Some("Hello & world")); - assert!(reader.has_value()); - } - - #[test] - fn test_read_whitespace_only_text() { - let mut reader = XmlReader::new("<root> \n </root>"); - reader.read().unwrap(); // <root> - reader.read().unwrap(); // whitespace - assert_eq!(reader.node_type(), XmlNodeType::Whitespace); - assert_eq!(reader.value(), Some(" \n ")); - } - - #[test] - fn test_read_no_blanks_option() { - let opts = ParseOptions::default().no_blanks(true); - let mut reader = XmlReader::with_options("<root> <child/> </root>", opts); - - reader.read().unwrap(); // <root> - assert_eq!(reader.name(), Some("root")); - - reader.read().unwrap(); // <child/> (whitespace skipped) - assert_eq!(reader.node_type(), XmlNodeType::Element); - assert_eq!(reader.name(), Some("child")); - - reader.read().unwrap(); // </root> (whitespace skipped) - assert_eq!(reader.node_type(), XmlNodeType::EndElement); - assert_eq!(reader.name(), Some("root")); - } - - // === Test: comments, CDATA, PI === - - #[test] - fn test_read_comment() { - let nodes = read_all_types("<root><!-- hello --></root>"); - assert_eq!( - nodes, - vec![ - (XmlNodeType::Element, "root".to_string()), - (XmlNodeType::Comment, " hello ".to_string()), - (XmlNodeType::EndElement, "root".to_string()), - ] - ); - } - - #[test] - fn test_read_cdata() { - let nodes = read_all_types("<root><![CDATA[raw & data]]></root>"); - assert_eq!( - nodes, - vec![ - (XmlNodeType::Element, "root".to_string()), - (XmlNodeType::CData, "raw & data".to_string()), - (XmlNodeType::EndElement, "root".to_string()), - ] - ); - } - - #[test] - fn test_read_processing_instruction() { - let nodes = read_all_types("<?target data?><root/>"); - assert_eq!( - nodes, - vec![ - ( - XmlNodeType::ProcessingInstruction, - "target data".to_string() - ), - (XmlNodeType::Element, "root".to_string()), - ] - ); - } - - // === Test: XML declaration and doctype === - - #[test] - fn test_read_xml_declaration() { - let nodes = read_all_types("<?xml version=\"1.0\" encoding=\"UTF-8\"?><root/>"); - assert_eq!( - nodes, - vec![ - ( - XmlNodeType::XmlDeclaration, - "version=1.0 encoding=UTF-8".to_string() - ), - (XmlNodeType::Element, "root".to_string()), - ] - ); - } - - #[test] - fn test_read_doctype() { - let nodes = read_all_types("<!DOCTYPE html><html/>"); - assert_eq!( - nodes, - vec![ - (XmlNodeType::DocumentType, "html".to_string()), - (XmlNodeType::Element, "html".to_string()), - ] - ); - } - - // === Test: namespaces === - - #[test] - fn test_read_namespace() { - let mut reader = XmlReader::new("<root xmlns=\"http://example.com\"/>"); - reader.read().unwrap(); - assert_eq!(reader.name(), Some("root")); - assert_eq!(reader.namespace_uri(), Some("http://example.com")); - assert_eq!(reader.prefix(), None); - } - - #[test] - fn test_read_prefixed_namespace() { - let mut reader = XmlReader::new("<ns:root xmlns:ns=\"http://example.com\"/>"); - reader.read().unwrap(); - assert_eq!(reader.name(), Some("root")); - assert_eq!(reader.prefix(), Some("ns")); - assert_eq!(reader.namespace_uri(), Some("http://example.com")); - } - - #[test] - fn test_read_attribute_ns() { - let mut reader = XmlReader::new("<root xmlns:x=\"http://x.com\" x:attr=\"val\"/>"); - reader.read().unwrap(); - assert_eq!(reader.get_attribute("x:attr"), Some("val")); - assert_eq!(reader.get_attribute_ns("attr", "http://x.com"), Some("val")); - assert_eq!(reader.get_attribute_ns("attr", "http://other.com"), None); - } - - // === Test: mixed content === - - #[test] - fn test_read_mixed_content() { - let nodes = read_all_types("<p>Hello <b>world</b>!</p>"); - assert_eq!( - nodes, - vec![ - (XmlNodeType::Element, "p".to_string()), - (XmlNodeType::Text, "Hello ".to_string()), - (XmlNodeType::Element, "b".to_string()), - (XmlNodeType::Text, "world".to_string()), - (XmlNodeType::EndElement, "b".to_string()), - (XmlNodeType::Text, "!".to_string()), - (XmlNodeType::EndElement, "p".to_string()), - ] - ); - } - - // === Test: entity references === - - #[test] - fn test_read_entity_references() { - let mut reader = XmlReader::new("<root>&amp;&lt;&gt;&apos;&quot;</root>"); - reader.read().unwrap(); // <root> - reader.read().unwrap(); // text - assert_eq!(reader.value(), Some("&<>'\"")); - } - - // === Test: character references === - - #[test] - fn test_read_character_references() { - let mut reader = XmlReader::new("<root>&#65;&#x42;</root>"); - reader.read().unwrap(); // <root> - reader.read().unwrap(); // text "AB" - assert_eq!(reader.value(), Some("AB")); - } - - // === Test: error handling === - - #[test] - fn test_read_error_mismatched_tags() { - let mut reader = XmlReader::new("<a></b>"); - reader.read().unwrap(); // <a> - let result = reader.read(); // </b> should fail - // The read of text between <a> and </b> will give us the end tag. - // Actually there's no text, so we'll get the mismatched end tag error. - assert!(result.is_err()); - } - - #[test] - fn test_read_returns_false_after_end() { - let mut reader = XmlReader::new("<root/>"); - assert!(reader.read().unwrap()); // <root/> - assert!(!reader.read().unwrap()); // EOF - assert!(!reader.read().unwrap()); // still EOF - } - - // === Test: XmlNodeType Display === - - #[test] - fn test_node_type_display() { - assert_eq!(XmlNodeType::Element.to_string(), "Element"); - assert_eq!(XmlNodeType::EndElement.to_string(), "EndElement"); - assert_eq!(XmlNodeType::Text.to_string(), "Text"); - assert_eq!(XmlNodeType::None.to_string(), "None"); - assert_eq!(XmlNodeType::EndDocument.to_string(), "EndDocument"); - } - - // === Test: has_value returns false for elements === - - #[test] - fn test_has_value_element() { - let mut reader = XmlReader::new("<root/>"); - reader.read().unwrap(); - assert_eq!(reader.node_type(), XmlNodeType::Element); - assert!(!reader.has_value()); - } - - // === Test: value returns None for element === - - #[test] - fn test_value_none_for_element() { - let mut reader = XmlReader::new("<root/>"); - reader.read().unwrap(); - assert_eq!(reader.value(), None); - } - - // === Test: initial state === - - #[test] - fn test_initial_state() { - let reader = XmlReader::new("<root/>"); - assert_eq!(reader.node_type(), XmlNodeType::None); - assert_eq!(reader.name(), None); - assert_eq!(reader.value(), None); - assert!(!reader.has_value()); - assert_eq!(reader.depth(), 0); - assert_eq!(reader.attribute_count(), 0); - } - - // === Test: complex document === - - #[test] - fn test_read_complex_document() { - let xml = r#"<?xml version="1.0"?> -<!DOCTYPE doc> -<!-- prolog comment --> -<?style type="text/css"?> -<doc attr="val"> - <child>text</child> - <![CDATA[raw]]> - <!-- inner comment --> - <empty/> -</doc>"#; - let nodes = read_all_types(xml); - // Verify we get all the expected node types. - let types: Vec<XmlNodeType> = nodes.iter().map(|(t, _)| *t).collect(); - assert!(types.contains(&XmlNodeType::XmlDeclaration)); - assert!(types.contains(&XmlNodeType::DocumentType)); - assert!(types.contains(&XmlNodeType::Comment)); - assert!(types.contains(&XmlNodeType::ProcessingInstruction)); - assert!(types.contains(&XmlNodeType::Element)); - assert!(types.contains(&XmlNodeType::Text)); - assert!(types.contains(&XmlNodeType::CData)); - assert!(types.contains(&XmlNodeType::EndElement)); - } - - // === Test: prolog comments and PIs === - - #[test] - fn test_read_prolog_comment() { - let nodes = read_all_types("<!-- prolog --><root/>"); - assert_eq!( - nodes, - vec![ - (XmlNodeType::Comment, " prolog ".to_string()), - (XmlNodeType::Element, "root".to_string()), - ] - ); - } - - // === Test: trailing comments === - - #[test] - fn test_read_trailing_comment() { - let nodes = read_all_types("<root/><!-- trailing -->"); - assert_eq!( - nodes, - vec![ - (XmlNodeType::Element, "root".to_string()), - (XmlNodeType::Comment, " trailing ".to_string()), - ] - ); - } - - // === Test: move_to_element returns false when not on attribute === - - #[test] - fn test_move_to_element_when_not_on_attribute() { - let mut reader = XmlReader::new("<root/>"); - reader.read().unwrap(); - assert!(!reader.move_to_element()); - } - - // === Test: empty document === - - #[test] - fn test_read_empty_input() { - let mut reader = XmlReader::new(""); - assert!(!reader.read().unwrap()); - } - - // === Test: deeply nested === - - #[test] - fn test_read_deeply_nested() { - let mut reader = XmlReader::new("<a><b><c><d><e>deep</e></d></c></b></a>"); - - reader.read().unwrap(); // <a> depth=0 - assert_eq!(reader.depth(), 0); - reader.read().unwrap(); // <b> depth=1 - assert_eq!(reader.depth(), 1); - reader.read().unwrap(); // <c> depth=2 - assert_eq!(reader.depth(), 2); - reader.read().unwrap(); // <d> depth=3 - assert_eq!(reader.depth(), 3); - reader.read().unwrap(); // <e> depth=4 - assert_eq!(reader.depth(), 4); - reader.read().unwrap(); // "deep" - assert_eq!(reader.depth(), 5); - assert_eq!(reader.value(), Some("deep")); - } - - // === Test: single-quoted attributes === - - #[test] - fn test_read_single_quoted_attributes() { - let mut reader = XmlReader::new("<root attr='value'/>"); - reader.read().unwrap(); - assert_eq!(reader.get_attribute("attr"), Some("value")); - } -} diff --git a/browser/vendor/xmloxide/src/sax/mod.rs b/browser/vendor/xmloxide/src/sax/mod.rs deleted file mode 100644 index 4a628a083..000000000 --- a/browser/vendor/xmloxide/src/sax/mod.rs +++ /dev/null @@ -1,915 +0,0 @@ -//! SAX2 streaming event handler API. -//! -//! SAX (Simple API for XML) is a streaming, event-driven API for processing -//! XML. Instead of building a tree in memory, the parser fires callbacks as it -//! encounters elements, text, comments, and other XML constructs. -//! -//! This is useful for large documents where building a full tree would be -//! wasteful, or when you only need to extract specific data. -//! -//! # Examples -//! -//! ``` -//! use xmloxide::sax::{SaxHandler, parse_sax, DefaultHandler}; -//! use xmloxide::parser::ParseOptions; -//! -//! struct MyHandler { -//! element_count: usize, -//! } -//! -//! impl SaxHandler for MyHandler { -//! fn start_element( -//! &mut self, -//! local_name: &str, -//! _prefix: Option<&str>, -//! _namespace: Option<&str>, -//! _attributes: &[(String, String, Option<String>, Option<String>)], -//! ) { -//! self.element_count += 1; -//! } -//! } -//! -//! let mut handler = MyHandler { element_count: 0 }; -//! parse_sax("<root><a/><b/><c/></root>", &ParseOptions::default(), &mut handler).unwrap(); -//! assert_eq!(handler.element_count, 4); -//! ``` - -use crate::error::{ErrorSeverity, ParseError, SourceLocation}; -use crate::parser::input::{ - parse_cdata_content, parse_comment_content, parse_pi_content, parse_xml_decl, split_name, - NamespaceResolver, ParserInput, -}; -use crate::parser::ParseOptions; - -/// A SAX2 event handler trait. -/// -/// Implement the callbacks you care about; all methods have default no-op -/// implementations so you only need to override what you need. -/// -/// # Attribute tuples -/// -/// Attributes are passed as `(local_name, value, prefix, namespace_uri)` tuples. -#[allow(unused_variables)] -pub trait SaxHandler { - /// Called at the start of the document, before any other events. - fn start_document(&mut self) {} - - /// Called at the end of the document, after all other events. - fn end_document(&mut self) {} - - /// Called when an element start tag is encountered. - /// - /// `attributes` contains `(local_name, value, prefix, namespace_uri)` tuples. - fn start_element( - &mut self, - local_name: &str, - prefix: Option<&str>, - namespace: Option<&str>, - attributes: &[(String, String, Option<String>, Option<String>)], - ) { - } - - /// Called when an element end tag is encountered (or a self-closing tag ends). - fn end_element(&mut self, local_name: &str, prefix: Option<&str>, namespace: Option<&str>) {} - - /// Called for character data (text content). - fn characters(&mut self, content: &str) {} - - /// Called for CDATA sections. - fn cdata(&mut self, content: &str) {} - - /// Called for XML comments. - fn comment(&mut self, content: &str) {} - - /// Called for processing instructions. - fn processing_instruction(&mut self, target: &str, data: Option<&str>) {} - - /// Called when a warning is encountered during parsing. - fn warning(&mut self, message: &str, location: SourceLocation) {} - - /// Called when a recoverable error is encountered during parsing. - fn error(&mut self, message: &str, location: SourceLocation) {} -} - -/// A default no-op SAX handler. Useful as a base or for testing. -pub struct DefaultHandler; - -impl SaxHandler for DefaultHandler {} - -/// Parses XML from a string, firing SAX events on the provided handler. -/// -/// # Errors -/// -/// Returns `ParseError` if the input is not well-formed XML and recovery -/// mode is not enabled. -/// -/// # Examples -/// -/// ``` -/// use xmloxide::sax::{parse_sax, DefaultHandler}; -/// use xmloxide::parser::ParseOptions; -/// -/// let mut handler = DefaultHandler; -/// parse_sax("<root/>", &ParseOptions::default(), &mut handler).unwrap(); -/// ``` -pub fn parse_sax( - input: &str, - options: &ParseOptions, - handler: &mut dyn SaxHandler, -) -> Result<(), ParseError> { - let mut parser = SaxParser::new(input, options, handler); - let result = parser.parse(); - - // Transfer diagnostics from the shared input into any error that is - // returned, so callers see the full diagnostic trail. - if let Err(ref _e) = result { - // The error already contains diagnostics from ParserInput::fatal(). - } - - result -} - -/// The SAX-driven XML parser. -/// -/// Reuses the same parsing logic as the tree-building parser but fires -/// SAX events instead of constructing nodes. -struct SaxParser<'a, 'h> { - /// Shared low-level input state (position, peek, advance, name parsing, etc.). - input: ParserInput<'a>, - /// Parser options. - options: ParseOptions, - /// SAX event handler. - handler: &'h mut dyn SaxHandler, - /// Namespace resolver managing the scope stack. - ns: NamespaceResolver, -} - -impl<'a, 'h> SaxParser<'a, 'h> { - fn new(input: &'a str, options: &ParseOptions, handler: &'h mut dyn SaxHandler) -> Self { - let mut pi = ParserInput::new(input); - pi.set_recover(options.recover); - pi.set_max_depth(options.max_depth); - pi.set_max_name_length(options.max_name_length); - pi.set_max_entity_expansions(options.max_entity_expansions); - pi.set_entity_resolver(options.entity_resolver.clone()); - - Self { - input: pi, - options: options.clone(), - handler, - ns: NamespaceResolver::new(), - } - } - - fn parse(&mut self) -> Result<(), ParseError> { - self.handler.start_document(); - - // Parse optional XML declaration - self.input.skip_whitespace(); - if self.input.looking_at(b"<?xml ") - || self.input.looking_at(b"<?xml\t") - || self.input.looking_at(b"<?xml\r") - { - self.parse_xml_declaration()?; - } - - // Parse prolog misc - self.parse_misc()?; - - // Parse optional DOCTYPE - if self.input.looking_at(b"<!DOCTYPE") || self.input.looking_at(b"<!doctype") { - self.skip_doctype()?; - self.parse_misc()?; - } - - // Parse root element - if self.input.peek() == Some(b'<') - && self - .input - .peek_at(1) - .is_some_and(|b| b != b'!' && b != b'?') - { - self.parse_element()?; - } - - // Parse trailing misc - self.parse_misc()?; - - self.input.skip_whitespace(); - if !self.input.at_end() && !self.options.recover { - return Err(self.input.fatal("content after document element")); - } - - self.handler.end_document(); - Ok(()) - } - - // --- XML Declaration --- - // See XML 1.0 §2.8: [23] XMLDecl - - fn parse_xml_declaration(&mut self) -> Result<(), ParseError> { - // Delegate to the shared XML declaration parser; we discard the - // parsed values because the SAX API does not expose them. - let _decl = parse_xml_decl(&mut self.input)?; - Ok(()) - } - - // --- Misc (comments, PIs, whitespace) --- - - fn parse_misc(&mut self) -> Result<(), ParseError> { - loop { - self.input.skip_whitespace(); - if self.input.at_end() { - break; - } - if self.input.looking_at(b"<!--") { - self.parse_comment()?; - } else if self.input.looking_at(b"<?") { - self.parse_processing_instruction()?; - } else { - break; - } - } - Ok(()) - } - - // --- DOCTYPE Declaration --- - // See XML 1.0 §2.8: [28] doctypedecl - - fn skip_doctype(&mut self) -> Result<(), ParseError> { - self.input.expect_str(b"<!DOCTYPE")?; - self.input.skip_whitespace_required()?; - self.input.parse_name()?; - self.input.skip_whitespace(); - - if self.input.looking_at(b"SYSTEM") { - self.input.expect_str(b"SYSTEM")?; - self.input.skip_whitespace_required()?; - self.input.parse_quoted_value()?; - self.input.skip_whitespace(); - } else if self.input.looking_at(b"PUBLIC") { - self.input.expect_str(b"PUBLIC")?; - self.input.skip_whitespace_required()?; - self.input.parse_quoted_value()?; - self.input.skip_whitespace_required()?; - self.input.parse_quoted_value()?; - self.input.skip_whitespace(); - } - - if self.input.peek() == Some(b'[') { - self.input.advance(1); - let start = self.input.pos(); - let mut depth: u32 = 1; - while !self.input.at_end() && depth > 0 { - if self.input.looking_at(b"<!--") { - self.input.advance(4); - while !self.input.at_end() && !self.input.looking_at(b"-->") { - self.input.advance(1); - } - if !self.input.at_end() { - self.input.advance(3); - } - } else if let Some(b'"' | b'\'') = self.input.peek() { - let quote = self.input.peek().unwrap_or(b'"'); - self.input.advance(1); - while !self.input.at_end() && self.input.peek() != Some(quote) { - self.input.advance(1); - } - if !self.input.at_end() { - self.input.advance(1); - } - } else if self.input.peek() == Some(b'[') { - depth += 1; - self.input.advance(1); - } else if self.input.peek() == Some(b']') { - depth -= 1; - self.input.advance(1); - } else { - self.input.advance(1); - } - } - - // Parse DTD internal subset for entity declarations - let end = self.input.pos() - 1; - let subset_text = std::str::from_utf8(self.input.slice(start, end)) - .ok() - .map(str::to_string); - if let Some(subset_text) = subset_text { - if subset_text.contains('%') { - self.input.has_pe_references = true; - } - if let Ok(dtd) = crate::validation::dtd::parse_dtd(&subset_text) { - for (ent_name, ent_decl) in &dtd.entities { - match &ent_decl.kind { - crate::validation::dtd::EntityKind::Internal(value) => { - self.input - .entity_map - .insert(ent_name.clone(), value.clone()); - } - crate::validation::dtd::EntityKind::External { - system_id, - public_id, - } => { - self.input.entity_external.insert( - ent_name.clone(), - crate::parser::input::ExternalEntityInfo { - system_id: system_id.clone(), - public_id: public_id.clone(), - }, - ); - } - } - } - } - } - - self.input.skip_whitespace(); - } - - self.input.expect_byte(b'>')?; - Ok(()) - } - - // --- Elements --- - // See XML 1.0 §3.1: [40] STag, [42] ETag, [44] EmptyElemTag - - fn parse_element(&mut self) -> Result<(), ParseError> { - self.input.increment_depth()?; - self.input.expect_byte(b'<')?; - let name = self.input.parse_name()?; - - // Parse attributes as (full_name, value) pairs first - let mut raw_attrs: Vec<(String, String)> = Vec::new(); - loop { - let had_ws = self.input.skip_whitespace(); - if self.input.peek() == Some(b'>') || self.input.looking_at(b"/>") { - break; - } - if !had_ws { - return Err(self.input.fatal("whitespace required between attributes")); - } - let attr_name = self.input.parse_name()?; - self.input.skip_whitespace(); - self.input.expect_byte(b'=')?; - self.input.skip_whitespace(); - let attr_value = self.input.parse_attribute_value()?; - raw_attrs.push((attr_name, attr_value)); - } - - // Namespace processing: push scope and bind declarations. - self.ns.push_scope(); - for (attr_name, attr_value) in &raw_attrs { - if attr_name == "xmlns" { - self.ns.bind(None, attr_value.clone()); - } else if let Some(prefix) = attr_name.strip_prefix("xmlns:") { - self.ns.bind(Some(prefix.to_string()), attr_value.clone()); - } - } - - // Resolve element namespace - let (prefix, local_name) = split_name(&name); - let elem_ns = self.ns.resolve(prefix).map(String::from); - - // Build attribute tuples: (local_name, value, prefix, namespace) - let attributes: Vec<(String, String, Option<String>, Option<String>)> = raw_attrs - .iter() - .map(|(attr_name, attr_value)| { - let (attr_prefix, attr_local) = split_name(attr_name); - let attr_ns = if attr_prefix == Some("xmlns") - || (attr_prefix.is_none() && attr_local == "xmlns") - { - None - } else { - attr_prefix.and_then(|p| self.ns.resolve(Some(p)).map(String::from)) - }; - ( - attr_local.to_string(), - attr_value.clone(), - attr_prefix.map(String::from), - attr_ns, - ) - }) - .collect(); - - // Fire start_element - self.handler - .start_element(local_name, prefix, elem_ns.as_deref(), &attributes); - - let is_empty = self.input.looking_at(b"/>"); - if is_empty { - self.input.advance(2); - } else { - self.input.expect_byte(b'>')?; - self.parse_content()?; - self.input.expect_str(b"</")?; - let end_name = self.input.parse_name()?; - if end_name != name { - if self.options.recover { - self.input.push_diagnostic( - crate::error::ErrorSeverity::Error, - format!("mismatched end tag: expected </{name}>, found </{end_name}>"), - ); - } else { - return Err(self.input.fatal(format!( - "mismatched end tag: expected </{name}>, found </{end_name}>" - ))); - } - } - self.input.skip_whitespace(); - self.input.expect_byte(b'>')?; - } - - // Fire end_element - self.handler - .end_element(local_name, prefix, elem_ns.as_deref()); - - self.ns.pop_scope(); - self.input.decrement_depth(); - Ok(()) - } - - // --- Content --- - // See XML 1.0 §3.1: [43] content - - fn parse_content(&mut self) -> Result<(), ParseError> { - loop { - if self.input.at_end() { - if self.options.recover { - break; - } - return Err(self - .input - .fatal("unexpected end of input in element content")); - } - if self.input.looking_at(b"</") { - break; - } - if self.input.looking_at(b"<![CDATA[") { - self.parse_cdata()?; - } else if self.input.looking_at(b"<!--") { - self.parse_comment()?; - } else if self.input.looking_at(b"<?") { - self.parse_processing_instruction()?; - } else if self.input.peek() == Some(b'<') { - self.parse_element()?; - } else { - self.parse_char_data()?; - } - } - Ok(()) - } - - // --- Character Data --- - // See XML 1.0 §2.4: [14] CharData - - fn parse_char_data(&mut self) -> Result<(), ParseError> { - let mut text = String::new(); - while !self.input.at_end() { - // Bulk scan: find the next `<`, `&`, or `]]>` boundary - let safe_len = self.input.scan_char_data(); - if safe_len > 0 { - let start = self.input.pos(); - let chunk_bytes = self.input.slice(start, start + safe_len); - if chunk_bytes.contains(&b'\r') { - let chunk = std::str::from_utf8(chunk_bytes) - .map_err(|_| self.input.fatal("invalid UTF-8 in character data"))?; - let mut chars = chunk.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '\r' { - if chars.peek() == Some(&'\n') { - chars.next(); - } - text.push('\n'); - } else { - text.push(ch); - } - } - } else { - let chunk = std::str::from_utf8(chunk_bytes) - .map_err(|_| self.input.fatal("invalid UTF-8 in character data"))?; - text.push_str(chunk); - } - self.input.advance_counting_lines(safe_len); - continue; - } - - if self.input.peek() == Some(b'<') { - break; - } - - // XML 1.0 §2.4: "]]>" is forbidden in character data - if self.input.looking_at(b"]]>") { - if self.options.recover { - self.input.push_diagnostic( - ErrorSeverity::Error, - "']]>' not allowed in character data".to_string(), - ); - text.push_str("]]>"); - self.input.advance(3); - continue; - } - return Err(self.input.fatal("']]>' not allowed in character data")); - } - - if self.input.peek() == Some(b'&') { - self.input.parse_reference_into(&mut text)?; - } else { - let ch = self.input.next_char()?; - text.push(ch); - } - } - if !text.is_empty() { - if self.options.no_blanks && text.chars().all(char::is_whitespace) { - return Ok(()); - } - self.handler.characters(&text); - } - Ok(()) - } - - // --- Comments --- - // See XML 1.0 §2.5: [15] Comment - - fn parse_comment(&mut self) -> Result<(), ParseError> { - let content = parse_comment_content(&mut self.input)?; - self.handler.comment(&content); - Ok(()) - } - - // --- CDATA Sections --- - // See XML 1.0 §2.7: [18] CDSect - - fn parse_cdata(&mut self) -> Result<(), ParseError> { - let content = parse_cdata_content(&mut self.input)?; - self.handler.cdata(&content); - Ok(()) - } - - // --- Processing Instructions --- - // See XML 1.0 §2.6: [16] PI - - fn parse_processing_instruction(&mut self) -> Result<(), ParseError> { - let (target, data) = parse_pi_content(&mut self.input)?; - self.handler - .processing_instruction(&target, data.as_deref()); - Ok(()) - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - - // --- Test handler that records events --- - - #[derive(Debug, Default)] - struct RecordingHandler { - events: Vec<String>, - } - - impl SaxHandler for RecordingHandler { - fn start_document(&mut self) { - self.events.push("start_document".to_string()); - } - - fn end_document(&mut self) { - self.events.push("end_document".to_string()); - } - - fn start_element( - &mut self, - local_name: &str, - prefix: Option<&str>, - namespace: Option<&str>, - attributes: &[(String, String, Option<String>, Option<String>)], - ) { - use std::fmt::Write; - let mut event = format!("start_element({local_name}"); - if let Some(pfx) = prefix { - let _ = write!(event, ", prefix={pfx}"); - } - if let Some(ns) = namespace { - let _ = write!(event, ", ns={ns}"); - } - for (name, value, _, _) in attributes { - let _ = write!(event, ", {name}={value}"); - } - event.push(')'); - self.events.push(event); - } - - fn end_element( - &mut self, - local_name: &str, - prefix: Option<&str>, - _namespace: Option<&str>, - ) { - let event = match prefix { - Some(pfx) => format!("end_element({pfx}:{local_name})"), - None => format!("end_element({local_name})"), - }; - self.events.push(event); - } - - fn characters(&mut self, content: &str) { - self.events.push(format!("characters({content})")); - } - - fn cdata(&mut self, content: &str) { - self.events.push(format!("cdata({content})")); - } - - fn comment(&mut self, content: &str) { - self.events.push(format!("comment({content})")); - } - - fn processing_instruction(&mut self, target: &str, data: Option<&str>) { - match data { - Some(d) => self.events.push(format!("pi({target}, {d})")), - None => self.events.push(format!("pi({target})")), - } - } - - fn warning(&mut self, message: &str, _location: SourceLocation) { - self.events.push(format!("warning({message})")); - } - - fn error(&mut self, message: &str, _location: SourceLocation) { - self.events.push(format!("error({message})")); - } - } - - fn parse_events(input: &str) -> Vec<String> { - let mut handler = RecordingHandler::default(); - parse_sax(input, &ParseOptions::default(), &mut handler).unwrap(); - handler.events - } - - #[test] - fn test_sax_empty_element() { - let events = parse_events("<root/>"); - assert_eq!( - events, - vec![ - "start_document", - "start_element(root)", - "end_element(root)", - "end_document", - ] - ); - } - - #[test] - fn test_sax_element_with_text() { - let events = parse_events("<root>Hello</root>"); - assert_eq!( - events, - vec![ - "start_document", - "start_element(root)", - "characters(Hello)", - "end_element(root)", - "end_document", - ] - ); - } - - #[test] - fn test_sax_nested_elements() { - let events = parse_events("<a><b>text</b></a>"); - assert_eq!( - events, - vec![ - "start_document", - "start_element(a)", - "start_element(b)", - "characters(text)", - "end_element(b)", - "end_element(a)", - "end_document", - ] - ); - } - - #[test] - fn test_sax_attributes() { - let events = parse_events("<root id=\"1\" class=\"big\"/>"); - assert_eq!( - events, - vec![ - "start_document", - "start_element(root, id=1, class=big)", - "end_element(root)", - "end_document", - ] - ); - } - - #[test] - fn test_sax_comment() { - let events = parse_events("<root><!-- hello --></root>"); - assert_eq!( - events, - vec![ - "start_document", - "start_element(root)", - "comment( hello )", - "end_element(root)", - "end_document", - ] - ); - } - - #[test] - fn test_sax_cdata() { - let events = parse_events("<root><![CDATA[raw & data]]></root>"); - assert_eq!( - events, - vec![ - "start_document", - "start_element(root)", - "cdata(raw & data)", - "end_element(root)", - "end_document", - ] - ); - } - - #[test] - fn test_sax_processing_instruction() { - let events = parse_events("<?target data?><root/>"); - assert_eq!( - events, - vec![ - "start_document", - "pi(target, data)", - "start_element(root)", - "end_element(root)", - "end_document", - ] - ); - } - - #[test] - fn test_sax_entity_references() { - let events = parse_events("<root>&amp;&lt;&gt;</root>"); - assert_eq!( - events, - vec![ - "start_document", - "start_element(root)", - "characters(&<>)", - "end_element(root)", - "end_document", - ] - ); - } - - #[test] - fn test_sax_mixed_content() { - let events = parse_events("<p>Hello <b>world</b>!</p>"); - assert_eq!( - events, - vec![ - "start_document", - "start_element(p)", - "characters(Hello )", - "start_element(b)", - "characters(world)", - "end_element(b)", - "characters(!)", - "end_element(p)", - "end_document", - ] - ); - } - - #[test] - fn test_sax_namespace() { - let events = parse_events("<root xmlns=\"http://example.com\"><child/></root>"); - assert_eq!( - events, - vec![ - "start_document", - "start_element(root, ns=http://example.com, xmlns=http://example.com)", - "start_element(child, ns=http://example.com)", - "end_element(child)", - "end_element(root)", - "end_document", - ] - ); - } - - #[test] - fn test_sax_prefixed_namespace() { - let events = parse_events("<ns:root xmlns:ns=\"http://example.com\"/>"); - assert_eq!( - events, - vec![ - "start_document", - "start_element(root, prefix=ns, ns=http://example.com, ns=http://example.com)", - "end_element(ns:root)", - "end_document", - ] - ); - } - - #[test] - fn test_sax_doctype() { - // DOCTYPE should be silently skipped, not cause an error - let events = parse_events("<!DOCTYPE html><html/>"); - assert_eq!( - events, - vec![ - "start_document", - "start_element(html)", - "end_element(html)", - "end_document", - ] - ); - } - - #[test] - fn test_sax_xml_declaration() { - let events = parse_events("<?xml version=\"1.0\" encoding=\"UTF-8\"?><root/>"); - assert_eq!( - events, - vec![ - "start_document", - "start_element(root)", - "end_element(root)", - "end_document", - ] - ); - } - - #[test] - fn test_sax_element_count() { - struct Counter { - count: usize, - } - impl SaxHandler for Counter { - fn start_element( - &mut self, - _local_name: &str, - _prefix: Option<&str>, - _namespace: Option<&str>, - _attributes: &[(String, String, Option<String>, Option<String>)], - ) { - self.count += 1; - } - } - - let mut counter = Counter { count: 0 }; - parse_sax( - "<root><a/><b><c/></b><d/></root>", - &ParseOptions::default(), - &mut counter, - ) - .unwrap(); - assert_eq!(counter.count, 5); - } - - #[test] - fn test_sax_text_extraction() { - struct TextCollector { - text: String, - } - impl SaxHandler for TextCollector { - fn characters(&mut self, content: &str) { - self.text.push_str(content); - } - } - - let mut collector = TextCollector { - text: String::new(), - }; - parse_sax( - "<root>Hello <b>world</b>!</root>", - &ParseOptions::default(), - &mut collector, - ) - .unwrap(); - assert_eq!(collector.text, "Hello world!"); - } - - #[test] - fn test_sax_default_handler() { - // DefaultHandler should just work without panicking - let mut handler = DefaultHandler; - parse_sax( - "<root><child/></root>", - &ParseOptions::default(), - &mut handler, - ) - .unwrap(); - } - - #[test] - fn test_sax_error_mismatched_tags() { - let mut handler = DefaultHandler; - let result = parse_sax("<a></b>", &ParseOptions::default(), &mut handler); - assert!(result.is_err()); - } -} diff --git a/browser/vendor/xmloxide/src/serde_xml/de.rs b/browser/vendor/xmloxide/src/serde_xml/de.rs deleted file mode 100644 index d1b1fe180..000000000 --- a/browser/vendor/xmloxide/src/serde_xml/de.rs +++ /dev/null @@ -1,613 +0,0 @@ -//! XML Deserializer backed by the xmloxide DOM tree. - -use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor}; -use serde::Deserialize; - -use crate::tree::{Document, NodeId, NodeKind}; - -use super::Error; - -/// Deserializes a Rust value from an XML string. -/// -/// The root element maps to the top-level struct. -/// -/// # Errors -/// -/// Returns an error if parsing or deserialization fails. -pub fn from_str<'de, T: Deserialize<'de>>(xml: &str) -> Result<T, Error> { - let doc = Document::parse_str(xml)?; - let root = doc - .root_element() - .ok_or_else(|| Error::Message("no root element".to_string()))?; - let de = Deserializer::new(&doc, root); - T::deserialize(de) -} - -/// An XML element deserializer. -struct Deserializer<'a> { - doc: &'a Document, - node: NodeId, -} - -impl<'a> Deserializer<'a> { - fn new(doc: &'a Document, node: NodeId) -> Self { - Self { doc, node } - } - - /// Collects the text content of this element (concatenating child text nodes). - fn text_content(&self) -> String { - self.doc.text_content(self.node) - } -} - -impl<'de> de::Deserializer<'de> for Deserializer<'_> { - type Error = Error; - - fn deserialize_any<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - self.deserialize_map(visitor) - } - - fn deserialize_bool<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - let text = self.text_content(); - match text.as_str() { - "true" | "1" => visitor.visit_bool(true), - "false" | "0" => visitor.visit_bool(false), - _ => Err(Error::Message(format!("invalid bool: {text}"))), - } - } - - fn deserialize_i8<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - let text = self.text_content(); - visitor.visit_i8( - text.parse() - .map_err(|_| Error::Message(format!("invalid i8: {text}")))?, - ) - } - - fn deserialize_i16<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - let text = self.text_content(); - visitor.visit_i16( - text.parse() - .map_err(|_| Error::Message(format!("invalid i16: {text}")))?, - ) - } - - fn deserialize_i32<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - let text = self.text_content(); - visitor.visit_i32( - text.parse() - .map_err(|_| Error::Message(format!("invalid i32: {text}")))?, - ) - } - - fn deserialize_i64<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - let text = self.text_content(); - visitor.visit_i64( - text.parse() - .map_err(|_| Error::Message(format!("invalid i64: {text}")))?, - ) - } - - fn deserialize_u8<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - let text = self.text_content(); - visitor.visit_u8( - text.parse() - .map_err(|_| Error::Message(format!("invalid u8: {text}")))?, - ) - } - - fn deserialize_u16<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - let text = self.text_content(); - visitor.visit_u16( - text.parse() - .map_err(|_| Error::Message(format!("invalid u16: {text}")))?, - ) - } - - fn deserialize_u32<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - let text = self.text_content(); - visitor.visit_u32( - text.parse() - .map_err(|_| Error::Message(format!("invalid u32: {text}")))?, - ) - } - - fn deserialize_u64<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - let text = self.text_content(); - visitor.visit_u64( - text.parse() - .map_err(|_| Error::Message(format!("invalid u64: {text}")))?, - ) - } - - fn deserialize_f32<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - let text = self.text_content(); - visitor.visit_f32( - text.parse() - .map_err(|_| Error::Message(format!("invalid f32: {text}")))?, - ) - } - - fn deserialize_f64<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - let text = self.text_content(); - visitor.visit_f64( - text.parse() - .map_err(|_| Error::Message(format!("invalid f64: {text}")))?, - ) - } - - fn deserialize_char<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - let text = self.text_content(); - let mut chars = text.chars(); - let c = chars - .next() - .ok_or_else(|| Error::Message("empty char".to_string()))?; - if chars.next().is_some() { - return Err(Error::Message(format!("expected single char, got: {text}"))); - } - visitor.visit_char(c) - } - - fn deserialize_str<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - visitor.visit_string(self.text_content()) - } - - fn deserialize_string<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - visitor.visit_string(self.text_content()) - } - - fn deserialize_bytes<V: Visitor<'de>>(self, _visitor: V) -> Result<V::Value, Self::Error> { - Err(Error::Message("bytes not supported in XML".to_string())) - } - - fn deserialize_byte_buf<V: Visitor<'de>>(self, _visitor: V) -> Result<V::Value, Self::Error> { - Err(Error::Message("byte_buf not supported in XML".to_string())) - } - - fn deserialize_option<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - visitor.visit_some(self) - } - - fn deserialize_unit<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - visitor.visit_unit() - } - - fn deserialize_unit_struct<V: Visitor<'de>>( - self, - _name: &'static str, - visitor: V, - ) -> Result<V::Value, Self::Error> { - visitor.visit_unit() - } - - fn deserialize_newtype_struct<V: Visitor<'de>>( - self, - _name: &'static str, - visitor: V, - ) -> Result<V::Value, Self::Error> { - visitor.visit_newtype_struct(self) - } - - fn deserialize_seq<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - let children: Vec<NodeId> = self - .doc - .children(self.node) - .filter(|&c| matches!(self.doc.node(c).kind, NodeKind::Element { .. })) - .collect(); - visitor.visit_seq(SeqDeserializer { - doc: self.doc, - children, - index: 0, - }) - } - - fn deserialize_tuple<V: Visitor<'de>>( - self, - _len: usize, - visitor: V, - ) -> Result<V::Value, Self::Error> { - self.deserialize_seq(visitor) - } - - fn deserialize_tuple_struct<V: Visitor<'de>>( - self, - _name: &'static str, - _len: usize, - visitor: V, - ) -> Result<V::Value, Self::Error> { - self.deserialize_seq(visitor) - } - - fn deserialize_map<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - visitor.visit_map(ElementMapAccess::new(self.doc, self.node)) - } - - fn deserialize_struct<V: Visitor<'de>>( - self, - _name: &'static str, - _fields: &'static [&'static str], - visitor: V, - ) -> Result<V::Value, Self::Error> { - self.deserialize_map(visitor) - } - - fn deserialize_enum<V: Visitor<'de>>( - self, - _name: &'static str, - _variants: &'static [&'static str], - visitor: V, - ) -> Result<V::Value, Self::Error> { - let text = self.text_content(); - visitor.visit_enum(StringIntoDeserializer(text)) - } - - fn deserialize_identifier<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - self.deserialize_string(visitor) - } - - fn deserialize_ignored_any<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - visitor.visit_unit() - } -} - -/// Map access over an XML element: yields attributes (as `$attr:name`) and child elements. -struct ElementMapAccess<'a> { - doc: &'a Document, - node: NodeId, - attrs: Vec<(String, String)>, - children: Vec<(String, Vec<NodeId>)>, - index: usize, - keys: Vec<String>, -} - -impl<'a> ElementMapAccess<'a> { - fn new(doc: &'a Document, node: NodeId) -> Self { - let attrs: Vec<(String, String)> = doc - .attributes(node) - .iter() - .map(|a| (format!("$attr:{}", a.name), a.value.clone())) - .collect(); - - let mut child_map: Vec<(String, Vec<NodeId>)> = Vec::new(); - for child_id in doc.children(node) { - if let NodeKind::Element { ref name, .. } = doc.node(child_id).kind { - if let Some(entry) = child_map.iter_mut().find(|(n, _)| n == name) { - entry.1.push(child_id); - } else { - child_map.push((name.clone(), vec![child_id])); - } - } - } - - let has_text = doc - .children(node) - .any(|c| matches!(doc.node(c).kind, NodeKind::Text { .. })); - - let mut keys: Vec<String> = attrs.iter().map(|(k, _)| k.clone()).collect(); - if has_text { - keys.push("$text".to_string()); - } - for (name, _) in &child_map { - keys.push(name.clone()); - } - - Self { - doc, - node, - attrs, - children: child_map, - index: 0, - keys, - } - } -} - -impl<'de> MapAccess<'de> for ElementMapAccess<'_> { - type Error = Error; - - fn next_key_seed<K: DeserializeSeed<'de>>( - &mut self, - seed: K, - ) -> Result<Option<K::Value>, Self::Error> { - if self.index >= self.keys.len() { - return Ok(None); - } - let key = &self.keys[self.index]; - seed.deserialize(de::value::StrDeserializer::new(key)) - .map(Some) - } - - fn next_value_seed<V: DeserializeSeed<'de>>( - &mut self, - seed: V, - ) -> Result<V::Value, Self::Error> { - let key = &self.keys[self.index]; - self.index += 1; - - if let Some(attr) = self.attrs.iter().find(|(k, _)| k == key) { - return seed.deserialize(de::value::StringDeserializer::new(attr.1.clone())); - } - - if key == "$text" { - let text = self.doc.text_content(self.node); - return seed.deserialize(de::value::StringDeserializer::new(text)); - } - - if let Some(entry) = self.children.iter().find(|(n, _)| n == key) { - let nodes = &entry.1; - if nodes.len() == 1 { - return seed.deserialize(Deserializer::new(self.doc, nodes[0])); - } - return seed.deserialize(SeqNodeDeserializer { - doc: self.doc, - nodes: nodes.clone(), - }); - } - - Err(Error::Message(format!("unexpected key: {key}"))) - } -} - -/// Deserializer that presents multiple nodes as a sequence. -struct SeqNodeDeserializer<'a> { - doc: &'a Document, - nodes: Vec<NodeId>, -} - -impl<'de> de::Deserializer<'de> for SeqNodeDeserializer<'_> { - type Error = Error; - - fn deserialize_any<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - self.deserialize_seq(visitor) - } - - fn deserialize_seq<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - visitor.visit_seq(SeqDeserializer { - doc: self.doc, - children: self.nodes, - index: 0, - }) - } - - serde::forward_to_deserialize_any! { - bool i8 i16 i32 i64 u8 u16 u32 u64 f32 f64 char str string bytes - byte_buf option unit unit_struct newtype_struct tuple tuple_struct - map struct enum identifier ignored_any - } -} - -/// Sequential access over a list of child nodes. -struct SeqDeserializer<'a> { - doc: &'a Document, - children: Vec<NodeId>, - index: usize, -} - -impl<'de> SeqAccess<'de> for SeqDeserializer<'_> { - type Error = Error; - - fn next_element_seed<T: DeserializeSeed<'de>>( - &mut self, - seed: T, - ) -> Result<Option<T::Value>, Self::Error> { - if self.index >= self.children.len() { - return Ok(None); - } - let node = self.children[self.index]; - self.index += 1; - seed.deserialize(Deserializer::new(self.doc, node)) - .map(Some) - } -} - -/// Helper: wraps a `String` as a serde enum deserializer for simple string enums. -struct StringEnumDeserializer(String); - -impl<'de> de::Deserializer<'de> for StringEnumDeserializer { - type Error = Error; - - fn deserialize_any<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> { - visitor.visit_string(self.0) - } - - serde::forward_to_deserialize_any! { - bool i8 i16 i32 i64 u8 u16 u32 u64 f32 f64 char str string bytes - byte_buf option unit unit_struct newtype_struct seq tuple tuple_struct - map struct enum identifier ignored_any - } -} - -/// Newtype for using `String` as an `EnumAccess` for unit variants. -struct StringIntoDeserializer(String); - -impl<'de> de::EnumAccess<'de> for StringIntoDeserializer { - type Error = Error; - type Variant = UnitVariantAccess; - - fn variant_seed<V: DeserializeSeed<'de>>( - self, - seed: V, - ) -> Result<(V::Value, Self::Variant), Self::Error> { - let val = seed.deserialize(StringEnumDeserializer(self.0))?; - Ok((val, UnitVariantAccess)) - } -} - -/// Unit variant access (no data associated with the variant). -struct UnitVariantAccess; - -impl<'de> de::VariantAccess<'de> for UnitVariantAccess { - type Error = Error; - - fn unit_variant(self) -> Result<(), Self::Error> { - Ok(()) - } - - fn newtype_variant_seed<T: DeserializeSeed<'de>>( - self, - _seed: T, - ) -> Result<T::Value, Self::Error> { - Err(Error::Message("newtype variant not supported".to_string())) - } - - fn tuple_variant<V: Visitor<'de>>( - self, - _len: usize, - _visitor: V, - ) -> Result<V::Value, Self::Error> { - Err(Error::Message("tuple variant not supported".to_string())) - } - - fn struct_variant<V: Visitor<'de>>( - self, - _fields: &'static [&'static str], - _visitor: V, - ) -> Result<V::Value, Self::Error> { - Err(Error::Message("struct variant not supported".to_string())) - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - use serde::Deserialize; - - #[test] - fn test_de_simple_struct() { - #[derive(Debug, Deserialize, PartialEq)] - struct Root { - name: String, - value: String, - } - let xml = "<Root><name>hello</name><value>world</value></Root>"; - let r: Root = from_str(xml).unwrap(); - assert_eq!(r.name, "hello"); - assert_eq!(r.value, "world"); - } - - #[test] - fn test_de_attributes() { - #[derive(Debug, Deserialize, PartialEq)] - struct Item { - #[serde(rename = "$attr:id")] - id: String, - #[serde(rename = "$attr:class")] - class: String, - } - let xml = r#"<Item id="1" class="foo"/>"#; - let item: Item = from_str(xml).unwrap(); - assert_eq!(item.id, "1"); - assert_eq!(item.class, "foo"); - } - - #[test] - fn test_de_text_content() { - #[derive(Debug, Deserialize, PartialEq)] - struct Msg { - #[serde(rename = "$text")] - text: String, - } - let xml = "<Msg>Hello World</Msg>"; - let msg: Msg = from_str(xml).unwrap(); - assert_eq!(msg.text, "Hello World"); - } - - #[test] - fn test_de_nested() { - #[derive(Debug, Deserialize, PartialEq)] - struct Inner { - #[serde(rename = "$text")] - text: String, - } - #[derive(Debug, Deserialize, PartialEq)] - struct Outer { - inner: Inner, - } - let xml = "<Outer><inner>data</inner></Outer>"; - let o: Outer = from_str(xml).unwrap(); - assert_eq!(o.inner.text, "data"); - } - - #[test] - fn test_de_sequence() { - #[derive(Debug, Deserialize, PartialEq)] - struct Item { - #[serde(rename = "$text")] - text: String, - } - #[derive(Debug, Deserialize, PartialEq)] - struct List { - item: Vec<Item>, - } - let xml = "<List><item>A</item><item>B</item><item>C</item></List>"; - let list: List = from_str(xml).unwrap(); - assert_eq!(list.item.len(), 3); - assert_eq!(list.item[0].text, "A"); - assert_eq!(list.item[2].text, "C"); - } - - #[test] - fn test_de_numeric() { - #[derive(Debug, Deserialize, PartialEq)] - struct Data { - count: u32, - ratio: f64, - } - let xml = "<Data><count>42</count><ratio>2.72</ratio></Data>"; - let d: Data = from_str(xml).unwrap(); - assert_eq!(d.count, 42); - assert!((d.ratio - 2.72).abs() < f64::EPSILON); - } - - #[test] - fn test_de_bool() { - #[derive(Debug, Deserialize, PartialEq)] - struct Flags { - active: bool, - visible: bool, - } - let xml = "<Flags><active>true</active><visible>false</visible></Flags>"; - let f: Flags = from_str(xml).unwrap(); - assert!(f.active); - assert!(!f.visible); - } - - #[test] - fn test_de_option_present() { - #[derive(Debug, Deserialize, PartialEq)] - struct Data { - #[serde(default)] - value: Option<String>, - } - let xml = "<Data><value>yes</value></Data>"; - let d: Data = from_str(xml).unwrap(); - assert_eq!(d.value, Some("yes".to_string())); - } - - #[test] - fn test_de_mixed_attrs_and_children() { - #[derive(Debug, Deserialize, PartialEq)] - struct Node { - #[serde(rename = "$attr:type")] - node_type: String, - child: String, - } - let xml = r#"<Node type="special"><child>data</child></Node>"#; - let n: Node = from_str(xml).unwrap(); - assert_eq!(n.node_type, "special"); - assert_eq!(n.child, "data"); - } - - #[test] - fn test_de_renamed_root() { - #[derive(Debug, Deserialize, PartialEq)] - #[serde(rename = "book")] - struct Book { - title: String, - } - let xml = "<book><title>Rust in Action</title></book>"; - let b: Book = from_str(xml).unwrap(); - assert_eq!(b.title, "Rust in Action"); - } -} diff --git a/browser/vendor/xmloxide/src/serde_xml/error.rs b/browser/vendor/xmloxide/src/serde_xml/error.rs deleted file mode 100644 index 803aa2fdf..000000000 --- a/browser/vendor/xmloxide/src/serde_xml/error.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! Serde error type for XML (de)serialization. - -use std::fmt; - -/// Error type for serde XML operations. -#[derive(Debug)] -pub enum Error { - /// A serde serialization/deserialization error. - Message(String), - /// An XML parsing error. - Parse(crate::error::ParseError), -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Message(msg) => write!(f, "{msg}"), - Self::Parse(e) => write!(f, "XML parse error: {e}"), - } - } -} - -impl std::error::Error for Error {} - -impl serde::de::Error for Error { - fn custom<T: fmt::Display>(msg: T) -> Self { - Self::Message(msg.to_string()) - } -} - -impl serde::ser::Error for Error { - fn custom<T: fmt::Display>(msg: T) -> Self { - Self::Message(msg.to_string()) - } -} - -impl From<crate::error::ParseError> for Error { - fn from(e: crate::error::ParseError) -> Self { - Self::Parse(e) - } -} diff --git a/browser/vendor/xmloxide/src/serde_xml/mod.rs b/browser/vendor/xmloxide/src/serde_xml/mod.rs deleted file mode 100644 index 8a412ca9e..000000000 --- a/browser/vendor/xmloxide/src/serde_xml/mod.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! Serde XML (de)serialization. -//! -//! This module provides `from_str` / `to_string` functions for converting between -//! XML text and Rust types via serde. It requires the `serde` feature. -//! -//! # Conventions -//! -//! - Element children map to struct fields by tag name -//! - Attributes are accessed via the `$attr` prefix: `#[serde(rename = "$attr:class")]` -//! - Text content is accessed via `$text`: `#[serde(rename = "$text")]` -//! - Sequences (repeated elements) are collected into `Vec<T>` -//! - The root element name is used as the struct name (or overridden via `#[serde(rename)]`) -//! -//! # Examples -//! -//! ``` -//! # #[cfg(feature = "serde")] -//! # { -//! use serde::{Deserialize, Serialize}; -//! -//! #[derive(Debug, Deserialize, Serialize, PartialEq)] -//! #[serde(rename = "book")] -//! struct Book { -//! #[serde(rename = "$attr:isbn")] -//! isbn: String, -//! title: String, -//! author: String, -//! } -//! -//! let xml = r#"<book isbn="978-0"><title>Rust</title><author>Alice</author></book>"#; -//! let book: Book = xmloxide::serde_xml::from_str(xml).unwrap(); -//! assert_eq!(book.isbn, "978-0"); -//! assert_eq!(book.title, "Rust"); -//! -//! let xml_out = xmloxide::serde_xml::to_string(&book).unwrap(); -//! assert!(xml_out.contains("<title>Rust</title>")); -//! # } -//! ``` - -mod de; -mod error; -mod ser; - -pub use de::from_str; -pub use error::Error; -pub use ser::to_string; diff --git a/browser/vendor/xmloxide/src/serde_xml/ser.rs b/browser/vendor/xmloxide/src/serde_xml/ser.rs deleted file mode 100644 index e80008fcd..000000000 --- a/browser/vendor/xmloxide/src/serde_xml/ser.rs +++ /dev/null @@ -1,887 +0,0 @@ -//! XML Serializer that produces XML strings from Rust types via serde. - -use serde::ser::{self, Serialize}; - -use super::Error; - -/// Serializes a Rust value to an XML string. -/// -/// The struct's name (or `#[serde(rename = "...")]`) becomes the root element. -/// Fields prefixed with `$attr:` become attributes. A field named `$text` -/// becomes the element's text content. -/// -/// # Errors -/// -/// Returns an error if serialization fails. -pub fn to_string<T: Serialize>(value: &T) -> Result<String, Error> { - let mut output = String::new(); - let serializer = XmlSerializer { - output: &mut output, - }; - value.serialize(serializer)?; - Ok(output) -} - -struct XmlSerializer<'a> { - output: &'a mut String, -} - -impl<'a> ser::Serializer for XmlSerializer<'a> { - type Ok = (); - type Error = Error; - type SerializeSeq = SeqSerializer<'a>; - type SerializeTuple = SeqSerializer<'a>; - type SerializeTupleStruct = SeqSerializer<'a>; - type SerializeTupleVariant = SeqSerializer<'a>; - type SerializeMap = MapSerializer<'a>; - type SerializeStruct = StructSerializer<'a>; - type SerializeStructVariant = StructSerializer<'a>; - - fn serialize_bool(self, v: bool) -> Result<Self::Ok, Self::Error> { - self.output.push_str(if v { "true" } else { "false" }); - Ok(()) - } - - fn serialize_i8(self, v: i8) -> Result<Self::Ok, Self::Error> { - self.output.push_str(&v.to_string()); - Ok(()) - } - - fn serialize_i16(self, v: i16) -> Result<Self::Ok, Self::Error> { - self.output.push_str(&v.to_string()); - Ok(()) - } - - fn serialize_i32(self, v: i32) -> Result<Self::Ok, Self::Error> { - self.output.push_str(&v.to_string()); - Ok(()) - } - - fn serialize_i64(self, v: i64) -> Result<Self::Ok, Self::Error> { - self.output.push_str(&v.to_string()); - Ok(()) - } - - fn serialize_u8(self, v: u8) -> Result<Self::Ok, Self::Error> { - self.output.push_str(&v.to_string()); - Ok(()) - } - - fn serialize_u16(self, v: u16) -> Result<Self::Ok, Self::Error> { - self.output.push_str(&v.to_string()); - Ok(()) - } - - fn serialize_u32(self, v: u32) -> Result<Self::Ok, Self::Error> { - self.output.push_str(&v.to_string()); - Ok(()) - } - - fn serialize_u64(self, v: u64) -> Result<Self::Ok, Self::Error> { - self.output.push_str(&v.to_string()); - Ok(()) - } - - fn serialize_f32(self, v: f32) -> Result<Self::Ok, Self::Error> { - self.output.push_str(&v.to_string()); - Ok(()) - } - - fn serialize_f64(self, v: f64) -> Result<Self::Ok, Self::Error> { - self.output.push_str(&v.to_string()); - Ok(()) - } - - fn serialize_char(self, v: char) -> Result<Self::Ok, Self::Error> { - escape_xml_to(self.output, &v.to_string()); - Ok(()) - } - - fn serialize_str(self, v: &str) -> Result<Self::Ok, Self::Error> { - escape_xml_to(self.output, v); - Ok(()) - } - - fn serialize_bytes(self, _v: &[u8]) -> Result<Self::Ok, Self::Error> { - Err(Error::Message("bytes not supported in XML".to_string())) - } - - fn serialize_none(self) -> Result<Self::Ok, Self::Error> { - Ok(()) - } - - fn serialize_some<T: ?Sized + Serialize>(self, value: &T) -> Result<Self::Ok, Self::Error> { - value.serialize(self) - } - - fn serialize_unit(self) -> Result<Self::Ok, Self::Error> { - Ok(()) - } - - fn serialize_unit_struct(self, _name: &'static str) -> Result<Self::Ok, Self::Error> { - Ok(()) - } - - fn serialize_unit_variant( - self, - _name: &'static str, - _variant_index: u32, - variant: &'static str, - ) -> Result<Self::Ok, Self::Error> { - self.output.push_str(variant); - Ok(()) - } - - fn serialize_newtype_struct<T: ?Sized + Serialize>( - self, - _name: &'static str, - value: &T, - ) -> Result<Self::Ok, Self::Error> { - value.serialize(self) - } - - fn serialize_newtype_variant<T: ?Sized + Serialize>( - self, - _name: &'static str, - _variant_index: u32, - variant: &'static str, - value: &T, - ) -> Result<Self::Ok, Self::Error> { - self.output.push('<'); - self.output.push_str(variant); - self.output.push('>'); - value.serialize(XmlSerializer { - output: self.output, - })?; - self.output.push_str("</"); - self.output.push_str(variant); - self.output.push('>'); - Ok(()) - } - - fn serialize_seq(self, _len: Option<usize>) -> Result<Self::SerializeSeq, Self::Error> { - Ok(SeqSerializer { - output: self.output, - }) - } - - fn serialize_tuple(self, _len: usize) -> Result<Self::SerializeTuple, Self::Error> { - Ok(SeqSerializer { - output: self.output, - }) - } - - fn serialize_tuple_struct( - self, - _name: &'static str, - _len: usize, - ) -> Result<Self::SerializeTupleStruct, Self::Error> { - Ok(SeqSerializer { - output: self.output, - }) - } - - fn serialize_tuple_variant( - self, - _name: &'static str, - _variant_index: u32, - _variant: &'static str, - _len: usize, - ) -> Result<Self::SerializeTupleVariant, Self::Error> { - Ok(SeqSerializer { - output: self.output, - }) - } - - fn serialize_map(self, _len: Option<usize>) -> Result<Self::SerializeMap, Self::Error> { - Ok(MapSerializer { - output: self.output, - current_key: None, - }) - } - - fn serialize_struct( - self, - name: &'static str, - _len: usize, - ) -> Result<Self::SerializeStruct, Self::Error> { - Ok(StructSerializer { - output: self.output, - tag: name.to_string(), - attrs: String::new(), - body: String::new(), - }) - } - - fn serialize_struct_variant( - self, - _name: &'static str, - _variant_index: u32, - variant: &'static str, - _len: usize, - ) -> Result<Self::SerializeStructVariant, Self::Error> { - Ok(StructSerializer { - output: self.output, - tag: variant.to_string(), - attrs: String::new(), - body: String::new(), - }) - } -} - -/// Serializer for struct fields — collects attrs and child elements, then emits XML. -struct StructSerializer<'a> { - output: &'a mut String, - tag: String, - attrs: String, - body: String, -} - -impl ser::SerializeStruct for StructSerializer<'_> { - type Ok = (); - type Error = Error; - - fn serialize_field<T: ?Sized + Serialize>( - &mut self, - key: &'static str, - value: &T, - ) -> Result<(), Self::Error> { - if let Some(attr_name) = key.strip_prefix("$attr:") { - let mut val_str = String::new(); - value.serialize(XmlSerializer { - output: &mut val_str, - })?; - self.attrs.push(' '); - self.attrs.push_str(attr_name); - self.attrs.push_str("=\""); - escape_xml_attr_to(&mut self.attrs, &val_str); - self.attrs.push('"'); - } else if key == "$text" { - value.serialize(XmlSerializer { - output: &mut self.body, - })?; - } else { - let mut child_buf = String::new(); - value.serialize(FieldSerializer { - output: &mut child_buf, - tag: key, - })?; - self.body.push_str(&child_buf); - } - Ok(()) - } - - fn end(self) -> Result<Self::Ok, Self::Error> { - self.output.push('<'); - self.output.push_str(&self.tag); - self.output.push_str(&self.attrs); - if self.body.is_empty() { - self.output.push_str("/>"); - } else { - self.output.push('>'); - self.output.push_str(&self.body); - self.output.push_str("</"); - self.output.push_str(&self.tag); - self.output.push('>'); - } - Ok(()) - } -} - -impl ser::SerializeStructVariant for StructSerializer<'_> { - type Ok = (); - type Error = Error; - - fn serialize_field<T: ?Sized + Serialize>( - &mut self, - key: &'static str, - value: &T, - ) -> Result<(), Self::Error> { - ser::SerializeStruct::serialize_field(self, key, value) - } - - fn end(self) -> Result<Self::Ok, Self::Error> { - ser::SerializeStruct::end(self) - } -} - -/// Serializer for a struct field that wraps scalar values in `<tag>...</tag>`. -/// For sequences (`Vec`), each element gets its own `<tag>` wrapper. -struct FieldSerializer<'a> { - output: &'a mut String, - tag: &'a str, -} - -impl FieldSerializer<'_> { - fn wrap_scalar(self, value: &str) { - self.output.push('<'); - self.output.push_str(self.tag); - self.output.push('>'); - self.output.push_str(value); - self.output.push_str("</"); - self.output.push_str(self.tag); - self.output.push('>'); - } - - fn wrap_scalar_escaped(self, value: &str) { - self.output.push('<'); - self.output.push_str(self.tag); - self.output.push('>'); - escape_xml_to(self.output, value); - self.output.push_str("</"); - self.output.push_str(self.tag); - self.output.push('>'); - } -} - -impl<'a> ser::Serializer for FieldSerializer<'a> { - type Ok = (); - type Error = Error; - type SerializeSeq = SeqFieldSerializer<'a>; - type SerializeTuple = SeqFieldSerializer<'a>; - type SerializeTupleStruct = SeqFieldSerializer<'a>; - type SerializeTupleVariant = SeqFieldSerializer<'a>; - type SerializeMap = MapSerializer<'a>; - type SerializeStruct = StructSerializer<'a>; - type SerializeStructVariant = StructSerializer<'a>; - - fn serialize_bool(self, v: bool) -> Result<Self::Ok, Self::Error> { - self.output.push('<'); - self.output.push_str(self.tag); - self.output.push('>'); - self.output.push_str(if v { "true" } else { "false" }); - self.output.push_str("</"); - self.output.push_str(self.tag); - self.output.push('>'); - Ok(()) - } - - fn serialize_i8(self, v: i8) -> Result<Self::Ok, Self::Error> { - self.wrap_scalar(&v.to_string()); - Ok(()) - } - fn serialize_i16(self, v: i16) -> Result<Self::Ok, Self::Error> { - self.wrap_scalar(&v.to_string()); - Ok(()) - } - fn serialize_i32(self, v: i32) -> Result<Self::Ok, Self::Error> { - self.wrap_scalar(&v.to_string()); - Ok(()) - } - fn serialize_i64(self, v: i64) -> Result<Self::Ok, Self::Error> { - self.wrap_scalar(&v.to_string()); - Ok(()) - } - fn serialize_u8(self, v: u8) -> Result<Self::Ok, Self::Error> { - self.wrap_scalar(&v.to_string()); - Ok(()) - } - fn serialize_u16(self, v: u16) -> Result<Self::Ok, Self::Error> { - self.wrap_scalar(&v.to_string()); - Ok(()) - } - fn serialize_u32(self, v: u32) -> Result<Self::Ok, Self::Error> { - self.wrap_scalar(&v.to_string()); - Ok(()) - } - fn serialize_u64(self, v: u64) -> Result<Self::Ok, Self::Error> { - self.wrap_scalar(&v.to_string()); - Ok(()) - } - fn serialize_f32(self, v: f32) -> Result<Self::Ok, Self::Error> { - self.wrap_scalar(&v.to_string()); - Ok(()) - } - fn serialize_f64(self, v: f64) -> Result<Self::Ok, Self::Error> { - self.wrap_scalar(&v.to_string()); - Ok(()) - } - - fn serialize_char(self, v: char) -> Result<Self::Ok, Self::Error> { - self.wrap_scalar_escaped(&v.to_string()); - Ok(()) - } - - fn serialize_str(self, v: &str) -> Result<Self::Ok, Self::Error> { - self.wrap_scalar_escaped(v); - Ok(()) - } - - fn serialize_bytes(self, _v: &[u8]) -> Result<Self::Ok, Self::Error> { - Err(Error::Message("bytes not supported".to_string())) - } - - fn serialize_none(self) -> Result<Self::Ok, Self::Error> { - Ok(()) - } - - fn serialize_some<T: ?Sized + Serialize>(self, value: &T) -> Result<Self::Ok, Self::Error> { - value.serialize(self) - } - - fn serialize_unit(self) -> Result<Self::Ok, Self::Error> { - self.output.push('<'); - self.output.push_str(self.tag); - self.output.push_str("/>"); - Ok(()) - } - - fn serialize_unit_struct(self, _name: &'static str) -> Result<Self::Ok, Self::Error> { - self.serialize_unit() - } - - fn serialize_unit_variant( - self, - _name: &'static str, - _variant_index: u32, - variant: &'static str, - ) -> Result<Self::Ok, Self::Error> { - self.wrap_scalar(variant); - Ok(()) - } - - fn serialize_newtype_struct<T: ?Sized + Serialize>( - self, - _name: &'static str, - value: &T, - ) -> Result<Self::Ok, Self::Error> { - value.serialize(self) - } - - fn serialize_newtype_variant<T: ?Sized + Serialize>( - self, - _name: &'static str, - _variant_index: u32, - _variant: &'static str, - value: &T, - ) -> Result<Self::Ok, Self::Error> { - value.serialize(self) - } - - fn serialize_seq(self, _len: Option<usize>) -> Result<Self::SerializeSeq, Self::Error> { - Ok(SeqFieldSerializer { - output: self.output, - tag: self.tag, - }) - } - - fn serialize_tuple(self, _len: usize) -> Result<Self::SerializeTuple, Self::Error> { - Ok(SeqFieldSerializer { - output: self.output, - tag: self.tag, - }) - } - - fn serialize_tuple_struct( - self, - _name: &'static str, - _len: usize, - ) -> Result<Self::SerializeTupleStruct, Self::Error> { - Ok(SeqFieldSerializer { - output: self.output, - tag: self.tag, - }) - } - - fn serialize_tuple_variant( - self, - _name: &'static str, - _variant_index: u32, - _variant: &'static str, - _len: usize, - ) -> Result<Self::SerializeTupleVariant, Self::Error> { - Ok(SeqFieldSerializer { - output: self.output, - tag: self.tag, - }) - } - - fn serialize_map(self, _len: Option<usize>) -> Result<Self::SerializeMap, Self::Error> { - Ok(MapSerializer { - output: self.output, - current_key: None, - }) - } - - fn serialize_struct( - self, - _name: &'static str, - _len: usize, - ) -> Result<Self::SerializeStruct, Self::Error> { - Ok(StructSerializer { - output: self.output, - tag: self.tag.to_string(), - attrs: String::new(), - body: String::new(), - }) - } - - fn serialize_struct_variant( - self, - _name: &'static str, - _variant_index: u32, - variant: &'static str, - _len: usize, - ) -> Result<Self::SerializeStructVariant, Self::Error> { - Ok(StructSerializer { - output: self.output, - tag: variant.to_string(), - attrs: String::new(), - body: String::new(), - }) - } -} - -/// Sequence serializer for top-level seq. -struct SeqSerializer<'a> { - output: &'a mut String, -} - -impl ser::SerializeSeq for SeqSerializer<'_> { - type Ok = (); - type Error = Error; - - fn serialize_element<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> { - value.serialize(XmlSerializer { - output: self.output, - }) - } - - fn end(self) -> Result<Self::Ok, Self::Error> { - Ok(()) - } -} - -impl ser::SerializeTuple for SeqSerializer<'_> { - type Ok = (); - type Error = Error; - - fn serialize_element<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> { - ser::SerializeSeq::serialize_element(self, value) - } - - fn end(self) -> Result<Self::Ok, Self::Error> { - ser::SerializeSeq::end(self) - } -} - -impl ser::SerializeTupleStruct for SeqSerializer<'_> { - type Ok = (); - type Error = Error; - - fn serialize_field<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> { - ser::SerializeSeq::serialize_element(self, value) - } - - fn end(self) -> Result<Self::Ok, Self::Error> { - ser::SerializeSeq::end(self) - } -} - -impl ser::SerializeTupleVariant for SeqSerializer<'_> { - type Ok = (); - type Error = Error; - - fn serialize_field<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> { - ser::SerializeSeq::serialize_element(self, value) - } - - fn end(self) -> Result<Self::Ok, Self::Error> { - ser::SerializeSeq::end(self) - } -} - -/// Sequence field serializer: each element gets wrapped in `<tag>...</tag>`. -struct SeqFieldSerializer<'a> { - output: &'a mut String, - tag: &'a str, -} - -impl ser::SerializeSeq for SeqFieldSerializer<'_> { - type Ok = (); - type Error = Error; - - fn serialize_element<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> { - value.serialize(FieldSerializer { - output: self.output, - tag: self.tag, - }) - } - - fn end(self) -> Result<Self::Ok, Self::Error> { - Ok(()) - } -} - -impl ser::SerializeTuple for SeqFieldSerializer<'_> { - type Ok = (); - type Error = Error; - - fn serialize_element<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> { - ser::SerializeSeq::serialize_element(self, value) - } - - fn end(self) -> Result<Self::Ok, Self::Error> { - ser::SerializeSeq::end(self) - } -} - -impl ser::SerializeTupleStruct for SeqFieldSerializer<'_> { - type Ok = (); - type Error = Error; - - fn serialize_field<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> { - ser::SerializeSeq::serialize_element(self, value) - } - - fn end(self) -> Result<Self::Ok, Self::Error> { - ser::SerializeSeq::end(self) - } -} - -impl ser::SerializeTupleVariant for SeqFieldSerializer<'_> { - type Ok = (); - type Error = Error; - - fn serialize_field<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> { - ser::SerializeSeq::serialize_element(self, value) - } - - fn end(self) -> Result<Self::Ok, Self::Error> { - ser::SerializeSeq::end(self) - } -} - -/// Map serializer. -struct MapSerializer<'a> { - output: &'a mut String, - current_key: Option<String>, -} - -impl ser::SerializeMap for MapSerializer<'_> { - type Ok = (); - type Error = Error; - - fn serialize_key<T: ?Sized + Serialize>(&mut self, key: &T) -> Result<(), Self::Error> { - let mut key_str = String::new(); - key.serialize(XmlSerializer { - output: &mut key_str, - })?; - self.current_key = Some(key_str); - Ok(()) - } - - fn serialize_value<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> { - let key = self - .current_key - .take() - .ok_or_else(|| Error::Message("serialize_value called without key".to_string()))?; - self.output.push('<'); - self.output.push_str(&key); - self.output.push('>'); - value.serialize(XmlSerializer { - output: self.output, - })?; - self.output.push_str("</"); - self.output.push_str(&key); - self.output.push('>'); - Ok(()) - } - - fn end(self) -> Result<Self::Ok, Self::Error> { - Ok(()) - } -} - -/// Escape XML special characters for text content. -fn escape_xml_to(output: &mut String, s: &str) { - for c in s.chars() { - match c { - '<' => output.push_str("&lt;"), - '>' => output.push_str("&gt;"), - '&' => output.push_str("&amp;"), - _ => output.push(c), - } - } -} - -/// Escape XML special characters for attribute values. -fn escape_xml_attr_to(output: &mut String, s: &str) { - for c in s.chars() { - match c { - '<' => output.push_str("&lt;"), - '>' => output.push_str("&gt;"), - '&' => output.push_str("&amp;"), - '"' => output.push_str("&quot;"), - _ => output.push(c), - } - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - use serde::Serialize; - - #[test] - fn test_ser_simple_struct() { - #[derive(Serialize)] - #[serde(rename = "root")] - struct Root { - name: String, - value: String, - } - let r = Root { - name: "hello".to_string(), - value: "world".to_string(), - }; - let xml = to_string(&r).unwrap(); - assert_eq!(xml, "<root><name>hello</name><value>world</value></root>"); - } - - #[test] - fn test_ser_attributes() { - #[derive(Serialize)] - #[serde(rename = "item")] - struct Item { - #[serde(rename = "$attr:id")] - id: String, - #[serde(rename = "$attr:class")] - class: String, - } - let item = Item { - id: "1".to_string(), - class: "foo".to_string(), - }; - let xml = to_string(&item).unwrap(); - assert_eq!(xml, r#"<item id="1" class="foo"/>"#); - } - - #[test] - fn test_ser_text_content() { - #[derive(Serialize)] - #[serde(rename = "msg")] - struct Msg { - #[serde(rename = "$text")] - text: String, - } - let msg = Msg { - text: "Hello World".to_string(), - }; - let xml = to_string(&msg).unwrap(); - assert_eq!(xml, "<msg>Hello World</msg>"); - } - - #[test] - fn test_ser_sequence() { - #[derive(Serialize)] - #[serde(rename = "item")] - struct Item { - #[serde(rename = "$text")] - text: String, - } - #[derive(Serialize)] - #[serde(rename = "list")] - struct List { - item: Vec<Item>, - } - let list = List { - item: vec![ - Item { - text: "A".to_string(), - }, - Item { - text: "B".to_string(), - }, - ], - }; - let xml = to_string(&list).unwrap(); - assert_eq!(xml, "<list><item>A</item><item>B</item></list>"); - } - - #[test] - fn test_ser_numeric() { - #[derive(Serialize)] - #[serde(rename = "data")] - struct Data { - count: u32, - ratio: f64, - } - let d = Data { - count: 42, - ratio: 2.72, - }; - let xml = to_string(&d).unwrap(); - assert_eq!(xml, "<data><count>42</count><ratio>2.72</ratio></data>"); - } - - #[test] - fn test_ser_escaping() { - #[derive(Serialize)] - #[serde(rename = "msg")] - struct Msg { - #[serde(rename = "$text")] - text: String, - } - let msg = Msg { - text: "<b>&amp;</b>".to_string(), - }; - let xml = to_string(&msg).unwrap(); - assert_eq!(xml, "<msg>&lt;b&gt;&amp;amp;&lt;/b&gt;</msg>"); - } - - #[test] - fn test_ser_attr_escaping() { - #[derive(Serialize)] - #[serde(rename = "item")] - struct Item { - #[serde(rename = "$attr:val")] - val: String, - } - let item = Item { - val: "a\"b".to_string(), - }; - let xml = to_string(&item).unwrap(); - assert_eq!(xml, r#"<item val="a&quot;b"/>"#); - } - - #[test] - fn test_ser_nested() { - #[derive(Serialize)] - #[serde(rename = "inner")] - struct Inner { - #[serde(rename = "$text")] - text: String, - } - #[derive(Serialize)] - #[serde(rename = "outer")] - struct Outer { - inner: Inner, - } - let o = Outer { - inner: Inner { - text: "data".to_string(), - }, - }; - let xml = to_string(&o).unwrap(); - assert_eq!(xml, "<outer><inner>data</inner></outer>"); - } - - #[test] - fn test_ser_none_omitted() { - #[derive(Serialize)] - #[serde(rename = "data")] - struct Data { - #[serde(skip_serializing_if = "Option::is_none")] - value: Option<String>, - name: String, - } - let d = Data { - value: None, - name: "test".to_string(), - }; - let xml = to_string(&d).unwrap(); - assert_eq!(xml, "<data><name>test</name></data>"); - } -} diff --git a/browser/vendor/xmloxide/src/serial/html.rs b/browser/vendor/xmloxide/src/serial/html.rs index a5390541b..4b90b6e36 100644 --- a/browser/vendor/xmloxide/src/serial/html.rs +++ b/browser/vendor/xmloxide/src/serial/html.rs @@ -193,37 +193,6 @@ fn write_unicode_attribute(out: &mut String, value: &str) { } } -/// Serializes a document produced by the HTML5 parser to an HTML string. -/// -/// Unlike [`serialize_html`] (which targets libxml2's HTML 4.01 output), -/// this function always preserves non-ASCII characters as raw UTF-8 and -/// uses self-closing syntax for foreign content elements (SVG, `MathML`). -/// -/// # Examples -/// -/// ``` -/// use xmloxide::html5::parse_html5; -/// use xmloxide::serial::html::serialize_html5; -/// -/// let doc = parse_html5("<p>Hello</p>").unwrap(); -/// let html = serialize_html5(&doc); -/// assert!(html.contains("<p>Hello</p>")); -/// ``` -#[must_use] -pub fn serialize_html5(doc: &Document) -> String { - let mut output = String::new(); - - for child in doc.children(doc.root()) { - serialize_html5_node(doc, child, &mut output); - } - - if !output.ends_with('\n') { - output.push('\n'); - } - - output -} - /// Detects whether the document declares a UTF-8 charset via `<meta>` tags. /// /// Checks for: @@ -692,164 +661,6 @@ fn write_html_escaped_attr(out: &mut String, text: &str, reencode: bool) { // HTML5 serialization // --------------------------------------------------------------------------- -/// HTML5 void elements (WHATWG §13.1.2). -fn is_html5_void(tag: &str) -> bool { - matches!( - tag, - "area" - | "base" - | "br" - | "col" - | "embed" - | "hr" - | "img" - | "input" - | "link" - | "meta" - | "source" - | "track" - | "wbr" - ) -} - -/// HTML5 raw text elements (content is not escaped). -fn is_html5_raw_text(tag: &str) -> bool { - matches!(tag, "script" | "style") -} - -/// Serialize a single node for HTML5 output. -fn serialize_html5_node(doc: &Document, id: NodeId, out: &mut String) { - match &doc.node(id).kind { - NodeKind::Element { - name, - namespace, - attributes, - .. - } => { - let is_foreign = namespace.as_deref().is_some_and(|ns| { - ns == "http://www.w3.org/2000/svg" || ns == "http://www.w3.org/1998/Math/MathML" - }); - - out.push('<'); - out.push_str(name); - - for attr in attributes { - out.push(' '); - if let Some(pfx) = &attr.prefix { - out.push_str(pfx); - out.push(':'); - } - out.push_str(&attr.name); - out.push_str("=\""); - write_html5_escaped_attr(out, &attr.value); - out.push('"'); - } - - let lower = name.to_ascii_lowercase(); - - // Void elements: no closing tag - if !is_foreign && is_html5_void(&lower) { - out.push('>'); - return; - } - - // Foreign content with no children: self-closing - if is_foreign && doc.first_child(id).is_none() { - out.push_str("/>"); - return; - } - - out.push('>'); - - // Raw text elements: output content without escaping - if is_html5_raw_text(&lower) { - for child in doc.children(id) { - if let NodeKind::Text { content } = &doc.node(child).kind { - out.push_str(content); - } - } - } else { - for child in doc.children(id) { - serialize_html5_node(doc, child, out); - } - } - - out.push_str("</"); - out.push_str(name); - out.push('>'); - } - NodeKind::Text { content } => { - write_html5_escaped_text(out, content); - } - NodeKind::Comment { content } => { - out.push_str("<!--"); - out.push_str(content); - out.push_str("-->"); - } - NodeKind::DocumentType { - name, - public_id, - system_id, - .. - } => { - out.push_str("<!DOCTYPE "); - out.push_str(name); - if let Some(pub_id) = public_id { - out.push_str(" PUBLIC \""); - out.push_str(pub_id); - out.push('"'); - if let Some(sys_id) = system_id { - out.push_str(" \""); - out.push_str(sys_id); - out.push('"'); - } - } else if let Some(sys_id) = system_id { - out.push_str(" SYSTEM \""); - out.push_str(sys_id); - out.push('"'); - } - out.push_str(">\n"); - } - NodeKind::ProcessingInstruction { target, data } => { - out.push_str("<?"); - out.push_str(target); - if let Some(d) = data { - out.push(' '); - out.push_str(d); - } - out.push('>'); - } - _ => { - for child in doc.children(id) { - serialize_html5_node(doc, child, out); - } - } - } -} - -/// Escape text content for HTML5 output (always UTF-8). -fn write_html5_escaped_text(out: &mut String, text: &str) { - for ch in text.chars() { - match ch { - '&' => out.push_str("&amp;"), - '<' => out.push_str("&lt;"), - '>' => out.push_str("&gt;"), - _ => out.push(ch), - } - } -} - -/// Escape an attribute value for HTML5 output. -fn write_html5_escaped_attr(out: &mut String, text: &str) { - for ch in text.chars() { - match ch { - '&' => out.push_str("&amp;"), - '"' => out.push_str("&quot;"), - _ => out.push(ch), - } - } -} - #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { @@ -1156,54 +967,4 @@ mod tests { "expected single-quoted attr, got: {html}" ); } - - // -- HTML5 serializer ---------------------------------------------------- - - #[test] - fn test_html5_basic_roundtrip() { - let doc = crate::html5::parse_html5("<p>Hello</p>").unwrap(); - let html = serialize_html5(&doc); - assert!(html.contains("<p>Hello</p>"), "got: {html}"); - assert!(html.contains("<html>"), "got: {html}"); - } - - #[test] - fn test_html5_void_elements() { - let doc = crate::html5::parse_html5("<br><hr><img src=\"x.png\">").unwrap(); - let html = serialize_html5(&doc); - assert!(html.contains("<br>"), "got: {html}"); - assert!(!html.contains("</br>"), "void should not close: {html}"); - assert!(html.contains("<hr>"), "got: {html}"); - assert!(html.contains("<img"), "got: {html}"); - } - - #[test] - fn test_html5_raw_text() { - let doc = crate::html5::parse_html5("<script>if (a < b) {}</script>").unwrap(); - let html = serialize_html5(&doc); - assert!( - html.contains("if (a < b) {}"), - "script content should not be escaped: {html}" - ); - } - - #[test] - fn test_html5_preserves_utf8() { - let doc = crate::html5::parse_html5("<p>café</p>").unwrap(); - let html = serialize_html5(&doc); - assert!(html.contains("café"), "UTF-8 should be preserved: {html}"); - } - - #[test] - fn test_html5_foreign_self_closing() { - let doc = - crate::html5::parse_html5("<svg><circle cx=\"50\" cy=\"50\" r=\"40\"/></svg>").unwrap(); - let html = serialize_html5(&doc); - assert!(html.contains("<circle"), "got: {html}"); - // Foreign empty elements should use self-closing syntax - assert!( - html.contains("/>"), - "foreign empty element should self-close: {html}" - ); - } } diff --git a/browser/vendor/xmloxide/src/validation/mod.rs b/browser/vendor/xmloxide/src/validation/mod.rs index 4e59dcafd..49e4d29b6 100644 --- a/browser/vendor/xmloxide/src/validation/mod.rs +++ b/browser/vendor/xmloxide/src/validation/mod.rs @@ -1,7 +1,6 @@ //! Document validation framework. //! -//! This module provides schema validation for XML documents, supporting -//! DTD, `RelaxNG`, XML Schema (XSD), and ISO Schematron. Each validator +//! This module provides schema validation for XML documents. Each validator //! parses its schema format and checks document conformance, returning a //! `ValidationResult` with errors and warnings. //! @@ -10,14 +9,11 @@ //! The validation module is organized into: //! - Common types (`ValidationResult`, `ValidationError`) used across all validators //! - DTD validation (`dtd` submodule) for XML 1.0 DTD processing -//! - `RelaxNG` validation (`relaxng` submodule) for `RelaxNG` schema validation -//! - XML Schema validation (`xsd` submodule) for XSD 1.0 validation -//! - Schematron validation (`schematron` submodule) for ISO Schematron rule-based validation +//! +//! This fork keeps only the DTD submodule (the XML parser depends on it); +//! upstream's `RelaxNG`, XSD, and Schematron validators are trimmed away. pub mod dtd; -pub mod relaxng; -pub mod schematron; -pub mod xsd; use std::fmt; diff --git a/browser/vendor/xmloxide/src/validation/relaxng.rs b/browser/vendor/xmloxide/src/validation/relaxng.rs deleted file mode 100644 index 5e210e8d1..000000000 --- a/browser/vendor/xmloxide/src/validation/relaxng.rs +++ /dev/null @@ -1,2479 +0,0 @@ -//! `RelaxNG` schema validation for XML documents. -//! -//! This module implements the `RelaxNG` specification -//! (<https://relaxng.org/spec-20011203.html>) for validating XML documents -//! against `RelaxNG` schemas. `RelaxNG` schemas are themselves XML documents -//! that describe the structure and content of valid XML. -//! -//! # Architecture -//! -//! The implementation is split into three layers: -//! -//! 1. **Data model** ([`Pattern`], [`NameClass`], [`RelaxNgSchema`]) — an -//! algebraic representation of the schema grammar. -//! 2. **Schema parser** ([`parse_relaxng`]) — reads a `RelaxNG` XML schema -//! document and produces a `RelaxNgSchema`. -//! 3. **Validator** ([`validate`]) — checks an XML document tree against a -//! compiled schema using a recursive pattern-matching approach. -//! -//! # Examples -//! -//! ``` -//! use xmloxide::Document; -//! use xmloxide::validation::relaxng::{parse_relaxng, validate}; -//! -//! let schema_xml = r#" -//! <element name="greeting" xmlns="http://relaxng.org/ns/structure/1.0"> -//! <text/> -//! </element> -//! "#; -//! -//! let schema = parse_relaxng(schema_xml).unwrap(); -//! let doc = Document::parse_str("<greeting>Hello!</greeting>").unwrap(); -//! let result = validate(&doc, &schema); -//! assert!(result.is_valid); -//! ``` - -use std::collections::HashMap; -use std::fmt; - -use crate::tree::{Document, NodeId, NodeKind}; -use crate::validation::{ValidationError, ValidationResult}; - -// --------------------------------------------------------------------------- -// Data model -// --------------------------------------------------------------------------- - -/// A `RelaxNG` pattern — the core building block of a schema grammar. -/// -/// Patterns form a tree that describes the allowed structure and content -/// of XML documents. The variants correspond to the grammar constructs -/// defined in the `RelaxNG` specification. -/// -/// See <https://relaxng.org/spec-20011203.html#section:patterns>. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Pattern { - /// Matches empty content (no elements, no text). - Empty, - /// Matches nothing — always fails. Used as an identity element for choice. - NotAllowed, - /// Matches arbitrary text content. - Text, - /// Matches an element whose name satisfies the [`NameClass`] and whose - /// content matches the inner pattern. - Element { - /// Name constraint for the element. - name: NameClass, - /// Pattern that the element's content must match. - pattern: Box<Pattern>, - }, - /// Matches an attribute whose name satisfies the [`NameClass`] and whose - /// value matches the inner pattern. - Attribute { - /// Name constraint for the attribute. - name: NameClass, - /// Pattern that the attribute value must match. - pattern: Box<Pattern>, - }, - /// Sequential composition — first pattern then second pattern. - Group(Box<Pattern>, Box<Pattern>), - /// Interleave — both patterns must match but in any order. - Interleave(Box<Pattern>, Box<Pattern>), - /// Choice — one of the two patterns must match. - Choice(Box<Pattern>, Box<Pattern>), - /// Optional — zero or one occurrence. - Optional(Box<Pattern>), - /// Zero or more occurrences. - ZeroOrMore(Box<Pattern>), - /// One or more occurrences. - OneOrMore(Box<Pattern>), - /// A named reference to a `<define>` block in the grammar. - Ref(String), - /// Matches a whitespace-separated list of tokens against the inner pattern. - List(Box<Pattern>), - /// Matches an exact string value. - Value(String), - /// Matches a value against a named datatype from a datatype library. - Data { - /// The datatype name (e.g., `"integer"`, `"string"`). - datatype: String, - /// The datatype library URI (e.g., the XML Schema datatypes namespace). - library: String, - }, - /// Matches a mixed content model (interleave of text and a pattern). - Mixed(Box<Pattern>), -} - -impl fmt::Display for Pattern { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Empty => write!(f, "empty"), - Self::NotAllowed => write!(f, "notAllowed"), - Self::Text => write!(f, "text"), - Self::Element { name, .. } => write!(f, "element {name}"), - Self::Attribute { name, .. } => write!(f, "attribute {name}"), - Self::Group(a, b) => write!(f, "group({a}, {b})"), - Self::Interleave(a, b) => write!(f, "interleave({a}, {b})"), - Self::Choice(a, b) => write!(f, "choice({a}, {b})"), - Self::Optional(p) => write!(f, "optional({p})"), - Self::ZeroOrMore(p) => write!(f, "zeroOrMore({p})"), - Self::OneOrMore(p) => write!(f, "oneOrMore({p})"), - Self::Ref(name) => write!(f, "ref({name})"), - Self::List(p) => write!(f, "list({p})"), - Self::Value(v) => write!(f, "value(\"{v}\")"), - Self::Data { datatype, .. } => write!(f, "data({datatype})"), - Self::Mixed(p) => write!(f, "mixed({p})"), - } - } -} - -/// A name class — constrains which element or attribute names are allowed. -/// -/// Name classes can match specific names, any name in a namespace, -/// any name at all, or combinations via choice and exclusion. -/// -/// See <https://relaxng.org/spec-20011203.html#section:name-classes>. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum NameClass { - /// Matches a specific name (namespace URI + local name). - Name { - /// The namespace URI (empty string for no namespace). - ns: String, - /// The local name of the element or attribute. - local: String, - }, - /// Matches any name regardless of namespace. - AnyName, - /// Matches any name except those matching the excluded name class. - AnyNameExcept(Box<NameClass>), - /// Matches any name in the given namespace. - NsName { - /// The namespace URI to match. - ns: String, - }, - /// Matches any name in the given namespace except those matching the - /// excluded name class. - NsNameExcept { - /// The namespace URI to match. - ns: String, - /// Names to exclude. - except: Box<NameClass>, - }, - /// Choice of two name classes — matches if either matches. - Choice(Box<NameClass>, Box<NameClass>), -} - -impl NameClass { - /// Tests whether this name class matches the given namespace and local name. - #[must_use] - pub fn matches(&self, ns: &str, local: &str) -> bool { - match self { - Self::Name { - ns: expected_ns, - local: expected_local, - } => expected_ns == ns && expected_local == local, - Self::AnyName => true, - Self::AnyNameExcept(except) => !except.matches(ns, local), - Self::NsName { ns: expected_ns } => expected_ns == ns, - Self::NsNameExcept { - ns: expected_ns, - except, - } => expected_ns == ns && !except.matches(ns, local), - Self::Choice(a, b) => a.matches(ns, local) || b.matches(ns, local), - } - } -} - -impl fmt::Display for NameClass { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Name { ns, local } => { - if ns.is_empty() { - write!(f, "{local}") - } else { - write!(f, "{{{ns}}}{local}") - } - } - Self::AnyName => write!(f, "*"), - Self::AnyNameExcept(except) => write!(f, "* - {except}"), - Self::NsName { ns } => write!(f, "{{{ns}}}*"), - Self::NsNameExcept { ns, except } => write!(f, "{{{ns}}}* - {except}"), - Self::Choice(a, b) => write!(f, "{a} | {b}"), - } - } -} - -/// A compiled `RelaxNG` schema ready for validation. -/// -/// Contains the start pattern (the entry point for validation) and a map -/// of named definitions that can be referenced via `Ref` patterns. -#[derive(Debug, Clone)] -pub struct RelaxNgSchema { - /// The start pattern — the root document must match this. - pub start: Pattern, - /// Named definitions (`<define name="...">` blocks). - pub defines: HashMap<String, Pattern>, -} - -// --------------------------------------------------------------------------- -// Schema parsing errors -// --------------------------------------------------------------------------- - -/// Error type for schema parsing failures. -#[derive(Debug, Clone)] -pub struct SchemaParseError { - /// Human-readable description of what went wrong. - pub message: String, -} - -impl fmt::Display for SchemaParseError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "RelaxNG schema error: {}", self.message) - } -} - -impl std::error::Error for SchemaParseError {} - -// --------------------------------------------------------------------------- -// Schema parser -// --------------------------------------------------------------------------- - -/// Parses a `RelaxNG` XML schema string into a [`RelaxNgSchema`]. -/// -/// The input must be a valid XML document conforming to the `RelaxNG` XML -/// syntax (<https://relaxng.org/spec-20011203.html>). Both compact-element -/// form (e.g., `<element name="foo">`) and verbose form with child `<name>` -/// elements are supported. -/// -/// # Errors -/// -/// Returns [`SchemaParseError`] if the input is not well-formed XML or does -/// not conform to the expected `RelaxNG` structure. -/// -/// # Examples -/// -/// ``` -/// use xmloxide::validation::relaxng::parse_relaxng; -/// -/// let schema = parse_relaxng(r#" -/// <element name="root" xmlns="http://relaxng.org/ns/structure/1.0"> -/// <empty/> -/// </element> -/// "#).unwrap(); -/// ``` -pub fn parse_relaxng(schema_xml: &str) -> Result<RelaxNgSchema, SchemaParseError> { - let doc = Document::parse_str(schema_xml).map_err(|e| SchemaParseError { - message: format!("failed to parse schema XML: {e}"), - })?; - - let root_el = doc.root_element().ok_or_else(|| SchemaParseError { - message: "schema has no root element".to_string(), - })?; - - let root_name = doc.node_name(root_el).unwrap_or(""); - - // Determine default namespace for elements from the `ns` attribute - // on the root schema element. - let default_ns = element_ns_attr(&doc, root_el); - - if strip_rng_prefix(root_name) == "grammar" { - parse_grammar(&doc, root_el, &default_ns) - } else if strip_rng_prefix(root_name) == "element" { - // Top-level element pattern (short form, no grammar wrapper). - let pattern = parse_element_pattern(&doc, root_el, &default_ns)?; - Ok(RelaxNgSchema { - start: pattern, - defines: HashMap::new(), - }) - } else { - Err(SchemaParseError { - message: format!("expected <grammar> or <element> as root, found <{root_name}>"), - }) - } -} - -/// Strips a `rng:` prefix from a name, if present, returning the local part. -fn strip_rng_prefix(name: &str) -> &str { - name.strip_prefix("rng:").unwrap_or(name) -} - -/// Returns the value of the `ns` attribute on an element, defaulting to -/// the empty string. -fn element_ns_attr(doc: &Document, node: NodeId) -> String { - doc.attribute(node, "ns").unwrap_or("").to_string() -} - -/// Parses a `<grammar>` element containing `<start>` and `<define>` children. -fn parse_grammar( - doc: &Document, - grammar_el: NodeId, - parent_ns: &str, -) -> Result<RelaxNgSchema, SchemaParseError> { - let mut start: Option<Pattern> = None; - let mut defines: HashMap<String, Pattern> = HashMap::new(); - - let ns = resolve_ns(doc, grammar_el, parent_ns); - - for child in doc.children(grammar_el) { - if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { - continue; - } - let child_name = doc.node_name(child).unwrap_or(""); - let local = strip_rng_prefix(child_name); - - match local { - "start" => { - let inner = parse_pattern_children(doc, child, &ns)?; - start = Some(inner); - } - "define" => { - let name = doc - .attribute(child, "name") - .ok_or_else(|| SchemaParseError { - message: "<define> missing 'name' attribute".to_string(), - })? - .to_string(); - let inner = parse_pattern_children(doc, child, &ns)?; - defines.insert(name, inner); - } - _ => { - // Ignore unknown elements (includes, divs, etc. — not yet - // implemented). - } - } - } - - let start = start.ok_or_else(|| SchemaParseError { - message: "<grammar> has no <start> element".to_string(), - })?; - - Ok(RelaxNgSchema { start, defines }) -} - -/// Resolves the effective namespace for pattern children. Uses the `ns` -/// attribute on the current element if present, otherwise inherits. -fn resolve_ns(doc: &Document, el: NodeId, parent_ns: &str) -> String { - doc.attribute(el, "ns") - .map_or_else(|| parent_ns.to_string(), String::from) -} - -/// Parses the child patterns of a container element (e.g., `<start>`, -/// `<group>`, `<choice>`). If there are multiple children, they are -/// implicitly grouped. -fn parse_pattern_children( - doc: &Document, - container: NodeId, - ns: &str, -) -> Result<Pattern, SchemaParseError> { - let patterns = collect_child_patterns(doc, container, ns)?; - combine_patterns(patterns) -} - -/// Collects all child pattern elements from a container node. -fn collect_child_patterns( - doc: &Document, - container: NodeId, - ns: &str, -) -> Result<Vec<Pattern>, SchemaParseError> { - let mut patterns = Vec::new(); - for child in doc.children(container) { - if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { - continue; - } - let p = parse_pattern(doc, child, ns)?; - patterns.push(p); - } - Ok(patterns) -} - -/// Combines a list of patterns using implicit `Group` (sequential composition). -fn combine_patterns(patterns: Vec<Pattern>) -> Result<Pattern, SchemaParseError> { - if patterns.is_empty() { - return Ok(Pattern::Empty); - } - - let mut iter = patterns.into_iter(); - let first = iter.next().ok_or_else(|| SchemaParseError { - message: "internal error: empty pattern list".to_string(), - })?; - - Ok(iter.fold(first, |acc, p| Pattern::Group(Box::new(acc), Box::new(p)))) -} - -/// Parses a single pattern element. -fn parse_pattern(doc: &Document, el: NodeId, parent_ns: &str) -> Result<Pattern, SchemaParseError> { - let name = doc.node_name(el).unwrap_or(""); - let local = strip_rng_prefix(name); - let ns = resolve_ns(doc, el, parent_ns); - - match local { - "element" => parse_element_pattern(doc, el, &ns), - "attribute" => parse_attribute_pattern(doc, el, &ns), - "group" => { - let children = collect_child_patterns(doc, el, &ns)?; - combine_patterns(children) - } - "interleave" => { - let children = collect_child_patterns(doc, el, &ns)?; - combine_interleave(children) - } - "choice" => { - let children = collect_child_patterns(doc, el, &ns)?; - combine_choice(children) - } - "optional" => { - let inner = parse_pattern_children(doc, el, &ns)?; - Ok(Pattern::Optional(Box::new(inner))) - } - "zeroOrMore" => { - let inner = parse_pattern_children(doc, el, &ns)?; - Ok(Pattern::ZeroOrMore(Box::new(inner))) - } - "oneOrMore" => { - let inner = parse_pattern_children(doc, el, &ns)?; - Ok(Pattern::OneOrMore(Box::new(inner))) - } - "mixed" => { - let inner = parse_pattern_children(doc, el, &ns)?; - Ok(Pattern::Mixed(Box::new(inner))) - } - "ref" => { - let ref_name = doc - .attribute(el, "name") - .ok_or_else(|| SchemaParseError { - message: "<ref> missing 'name' attribute".to_string(), - })? - .to_string(); - Ok(Pattern::Ref(ref_name)) - } - "text" => Ok(Pattern::Text), - "empty" => Ok(Pattern::Empty), - "notAllowed" => Ok(Pattern::NotAllowed), - "value" => { - let text = doc.text_content(el); - Ok(Pattern::Value(text)) - } - "data" => { - let datatype = doc.attribute(el, "type").unwrap_or("string").to_string(); - let library = doc - .attribute(el, "datatypeLibrary") - .unwrap_or("") - .to_string(); - Ok(Pattern::Data { datatype, library }) - } - "list" => { - let inner = parse_pattern_children(doc, el, &ns)?; - Ok(Pattern::List(Box::new(inner))) - } - other => Err(SchemaParseError { - message: format!("unknown pattern element <{other}>"), - }), - } -} - -/// Parses an `<element>` pattern, extracting the name class and content pattern. -fn parse_element_pattern( - doc: &Document, - el: NodeId, - ns: &str, -) -> Result<Pattern, SchemaParseError> { - let name_class = parse_name_class_from_element(doc, el, ns)?; - let el_ns = resolve_ns(doc, el, ns); - - // Collect content patterns (everything except <name>, <anyName>, <nsName>, - // <choice> when used as name class). - let mut content_patterns = Vec::new(); - for child in doc.children(el) { - if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { - continue; - } - let child_name = doc.node_name(child).unwrap_or(""); - let child_local = strip_rng_prefix(child_name); - // Skip name-class children — they're handled by parse_name_class. - if is_name_class_element(child_local) && doc.attribute(el, "name").is_none() { - continue; - } - let p = parse_pattern(doc, child, &el_ns)?; - content_patterns.push(p); - } - - let content = combine_patterns(content_patterns)?; - - Ok(Pattern::Element { - name: name_class, - pattern: Box::new(content), - }) -} - -/// Parses an `<attribute>` pattern, extracting the name class and value pattern. -fn parse_attribute_pattern( - doc: &Document, - el: NodeId, - ns: &str, -) -> Result<Pattern, SchemaParseError> { - let name_class = parse_name_class_from_element(doc, el, ns)?; - let attr_ns = resolve_ns(doc, el, ns); - - let mut content_patterns = Vec::new(); - for child in doc.children(el) { - if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { - continue; - } - let child_name = doc.node_name(child).unwrap_or(""); - let child_local = strip_rng_prefix(child_name); - if is_name_class_element(child_local) && doc.attribute(el, "name").is_none() { - continue; - } - let p = parse_pattern(doc, child, &attr_ns)?; - content_patterns.push(p); - } - - let content = if content_patterns.is_empty() { - Pattern::Text // default: attribute value is text - } else { - combine_patterns(content_patterns)? - }; - - Ok(Pattern::Attribute { - name: name_class, - pattern: Box::new(content), - }) -} - -/// Determines whether an element local name is a name-class element. -fn is_name_class_element(local: &str) -> bool { - matches!(local, "name" | "anyName" | "nsName" | "choice") -} - -/// Extracts a [`NameClass`] from an element or attribute pattern element. -/// -/// If the element has a `name` attribute, uses that directly. Otherwise, -/// looks for a child `<name>`, `<anyName>`, or `<nsName>` element. -fn parse_name_class_from_element( - doc: &Document, - el: NodeId, - ns: &str, -) -> Result<NameClass, SchemaParseError> { - // Check for `name` attribute shorthand. - if let Some(name_attr) = doc.attribute(el, "name") { - let el_ns = resolve_ns(doc, el, ns); - return Ok(NameClass::Name { - ns: el_ns, - local: name_attr.to_string(), - }); - } - - // Look for a name-class child element. - for child in doc.children(el) { - if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { - continue; - } - let child_name = doc.node_name(child).unwrap_or(""); - let child_local = strip_rng_prefix(child_name); - match child_local { - "name" => { - let local_name = doc.text_content(child); - let child_ns = resolve_ns(doc, child, ns); - return Ok(NameClass::Name { - ns: child_ns, - local: local_name.trim().to_string(), - }); - } - "anyName" => { - return parse_any_name_class(doc, child, ns); - } - "nsName" => { - return parse_ns_name_class(doc, child, ns); - } - "choice" => { - return parse_name_class_choice(doc, child, ns); - } - _ => {} - } - } - - Err(SchemaParseError { - message: "element/attribute pattern has no name or name class".to_string(), - }) -} - -/// Parses an `<anyName>` name class, possibly with `<except>`. -fn parse_any_name_class( - doc: &Document, - el: NodeId, - ns: &str, -) -> Result<NameClass, SchemaParseError> { - for child in doc.children(el) { - if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { - continue; - } - let child_name = doc.node_name(child).unwrap_or(""); - if strip_rng_prefix(child_name) == "except" { - let except = parse_name_class_children(doc, child, ns)?; - return Ok(NameClass::AnyNameExcept(Box::new(except))); - } - } - Ok(NameClass::AnyName) -} - -/// Parses an `<nsName>` name class, possibly with `<except>`. -fn parse_ns_name_class( - doc: &Document, - el: NodeId, - ns: &str, -) -> Result<NameClass, SchemaParseError> { - let target_ns = resolve_ns(doc, el, ns); - for child in doc.children(el) { - if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { - continue; - } - let child_name = doc.node_name(child).unwrap_or(""); - if strip_rng_prefix(child_name) == "except" { - let except = parse_name_class_children(doc, child, ns)?; - return Ok(NameClass::NsNameExcept { - ns: target_ns, - except: Box::new(except), - }); - } - } - Ok(NameClass::NsName { ns: target_ns }) -} - -/// Parses a `<choice>` element used as a name class. -fn parse_name_class_choice( - doc: &Document, - el: NodeId, - ns: &str, -) -> Result<NameClass, SchemaParseError> { - let mut classes = Vec::new(); - for child in doc.children(el) { - if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { - continue; - } - let child_name = doc.node_name(child).unwrap_or(""); - let child_local = strip_rng_prefix(child_name); - let nc = match child_local { - "name" => { - let local_name = doc.text_content(child).trim().to_string(); - let child_ns = resolve_ns(doc, child, ns); - NameClass::Name { - ns: child_ns, - local: local_name, - } - } - "anyName" => parse_any_name_class(doc, child, ns)?, - "nsName" => parse_ns_name_class(doc, child, ns)?, - "choice" => parse_name_class_choice(doc, child, ns)?, - _ => { - continue; - } - }; - classes.push(nc); - } - combine_name_classes(classes) -} - -/// Parses name class children inside an `<except>` or similar container. -fn parse_name_class_children( - doc: &Document, - container: NodeId, - ns: &str, -) -> Result<NameClass, SchemaParseError> { - let mut classes = Vec::new(); - for child in doc.children(container) { - if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { - continue; - } - let child_name = doc.node_name(child).unwrap_or(""); - let child_local = strip_rng_prefix(child_name); - let nc = match child_local { - "name" => { - let local_name = doc.text_content(child).trim().to_string(); - let child_ns = resolve_ns(doc, child, ns); - NameClass::Name { - ns: child_ns, - local: local_name, - } - } - "anyName" => parse_any_name_class(doc, child, ns)?, - "nsName" => parse_ns_name_class(doc, child, ns)?, - "choice" => parse_name_class_choice(doc, child, ns)?, - _ => { - continue; - } - }; - classes.push(nc); - } - combine_name_classes(classes) -} - -/// Combines multiple name classes using `Choice`. -fn combine_name_classes(classes: Vec<NameClass>) -> Result<NameClass, SchemaParseError> { - if classes.is_empty() { - return Err(SchemaParseError { - message: "empty name class".to_string(), - }); - } - let mut iter = classes.into_iter(); - let first = iter.next().ok_or_else(|| SchemaParseError { - message: "internal error: empty name class list".to_string(), - })?; - Ok(iter.fold(first, |acc, nc| { - NameClass::Choice(Box::new(acc), Box::new(nc)) - })) -} - -/// Combines patterns using `Interleave`. -fn combine_interleave(patterns: Vec<Pattern>) -> Result<Pattern, SchemaParseError> { - if patterns.is_empty() { - return Ok(Pattern::Empty); - } - let mut iter = patterns.into_iter(); - let first = iter.next().ok_or_else(|| SchemaParseError { - message: "internal error: empty interleave list".to_string(), - })?; - Ok(iter.fold(first, |acc, p| { - Pattern::Interleave(Box::new(acc), Box::new(p)) - })) -} - -/// Combines patterns using `Choice`. -fn combine_choice(patterns: Vec<Pattern>) -> Result<Pattern, SchemaParseError> { - if patterns.is_empty() { - return Ok(Pattern::NotAllowed); - } - let mut iter = patterns.into_iter(); - let first = iter.next().ok_or_else(|| SchemaParseError { - message: "internal error: empty choice list".to_string(), - })?; - Ok(iter.fold(first, |acc, p| Pattern::Choice(Box::new(acc), Box::new(p)))) -} - -// --------------------------------------------------------------------------- -// Validation -// --------------------------------------------------------------------------- - -/// Validates an XML document against a compiled [`RelaxNgSchema`]. -/// -/// The validator checks that the document's root element matches the -/// schema's start pattern, recursively verifying element names, attributes, -/// text content, and structural constraints. -/// -/// # Examples -/// -/// ``` -/// use xmloxide::Document; -/// use xmloxide::validation::relaxng::{parse_relaxng, validate}; -/// -/// let schema = parse_relaxng(r#" -/// <element name="root" xmlns="http://relaxng.org/ns/structure/1.0"> -/// <empty/> -/// </element> -/// "#).unwrap(); -/// -/// let doc = Document::parse_str("<root/>").unwrap(); -/// let result = validate(&doc, &schema); -/// assert!(result.is_valid); -/// ``` -#[must_use] -pub fn validate(doc: &Document, schema: &RelaxNgSchema) -> ValidationResult { - let mut errors = Vec::new(); - - let Some(root_el) = doc.root_element() else { - return ValidationResult { - is_valid: false, - errors: vec![ValidationError { - message: "document has no root element".to_string(), - line: None, - column: None, - }], - warnings: Vec::new(), - }; - }; - - let ctx = ValidationContext { - doc, - defines: &schema.defines, - }; - - let ok = ctx.validate_node(root_el, &schema.start, &mut errors); - - ValidationResult { - is_valid: ok && errors.is_empty(), - errors, - warnings: Vec::new(), - } -} - -/// Internal validation context — carries references to the document and schema. -struct ValidationContext<'a> { - doc: &'a Document, - defines: &'a HashMap<String, Pattern>, -} - -impl ValidationContext<'_> { - /// Validates a node against a pattern. Returns `true` if the node matches. - fn validate_node( - &self, - node: NodeId, - pattern: &Pattern, - errors: &mut Vec<ValidationError>, - ) -> bool { - match pattern { - Pattern::Element { - name, - pattern: inner, - } => self.validate_element(node, name, inner, errors), - Pattern::Choice(a, b) => { - // Try the first alternative silently; if it fails, try the second. - let mut a_errors = Vec::new(); - if self.validate_node(node, a, &mut a_errors) { - return true; - } - let mut b_errors = Vec::new(); - if self.validate_node(node, b, &mut b_errors) { - return true; - } - // Both failed — report the second branch errors (usually more - // informative for the "expected" case). - errors.extend(b_errors); - false - } - Pattern::Ref(ref_name) => { - if let Some(def) = self.defines.get(ref_name) { - self.validate_node(node, def, errors) - } else { - errors.push(ValidationError { - message: format!("undefined reference: {ref_name}"), - line: None, - column: None, - }); - false - } - } - _ => { - // For top-level validation, only Element patterns make sense - // as the start. If a non-element pattern appears at the root, - // it means the schema is unusual. - errors.push(ValidationError { - message: format!( - "expected document root to match {pattern}, \ - but root validation requires an element pattern" - ), - line: None, - column: None, - }); - false - } - } - } - - /// Validates an element node against an element pattern. - fn validate_element( - &self, - node: NodeId, - name_class: &NameClass, - content_pattern: &Pattern, - errors: &mut Vec<ValidationError>, - ) -> bool { - let node_kind = &self.doc.node(node).kind; - - // Ensure the node is actually an element. - let (el_name, el_ns) = if let NodeKind::Element { - name, namespace, .. - } = node_kind - { - (name.as_str(), namespace.as_deref().unwrap_or("")) - } else { - errors.push(ValidationError { - message: "expected element node".to_string(), - line: None, - column: None, - }); - return false; - }; - - // Check name. - if !name_class.matches(el_ns, el_name) { - errors.push(ValidationError { - message: format!( - "element name mismatch: found <{el_name}>, \ - expected {name_class}" - ), - line: None, - column: None, - }); - return false; - } - - // Validate content (attributes + children). - self.validate_content(node, content_pattern, errors) - } - - /// Validates the content of an element (attributes and child nodes) - /// against a content pattern. - fn validate_content( - &self, - element: NodeId, - pattern: &Pattern, - errors: &mut Vec<ValidationError>, - ) -> bool { - let attrs = self.doc.attributes(element); - let children: Vec<NodeId> = self.doc.children(element).collect(); - - // Separate attribute patterns from child-content patterns. - let (attr_patterns, content_pattern) = split_attributes(pattern); - - // Validate attributes. - let mut attr_ok = true; - let mut matched_attrs: Vec<bool> = vec![false; attrs.len()]; - - for ap in &attr_patterns { - if let Pattern::Attribute { - name: name_class, - pattern: value_pattern, - } = ap - { - let found = self.validate_attribute( - attrs, - &mut matched_attrs, - name_class, - value_pattern, - errors, - ); - if !found { - attr_ok = false; - } - } else if let Pattern::Optional(inner) = ap { - if let Pattern::Attribute { - name: name_class, - pattern: value_pattern, - } = inner.as_ref() - { - // Optional attribute: try to match but don't report error - // if missing. - let mut tmp_errors = Vec::new(); - let _ = self.validate_attribute( - attrs, - &mut matched_attrs, - name_class, - value_pattern, - &mut tmp_errors, - ); - // Ignore "missing" errors for optional attributes but - // keep value-mismatch errors. - for err in tmp_errors { - if !err.message.contains("missing required") { - errors.push(err); - attr_ok = false; - } - } - } - } - } - - // Check for unmatched (unexpected) attributes — but only if we had - // attribute patterns. If the content pattern is AnyName-style, we - // skip this check. - if !attr_patterns.is_empty() || !has_wildcard_attribute(pattern) { - for (i, attr) in attrs.iter().enumerate() { - if !matched_attrs[i] && !is_xmlns_attribute(attr) { - errors.push(ValidationError { - message: format!( - "unexpected attribute '{}' on element '{}'", - attr.name, - self.doc.node_name(element).unwrap_or("<unknown>") - ), - line: None, - column: None, - }); - attr_ok = false; - } - } - } - - // Validate child content. - let content_ok = self.validate_children(&children, &content_pattern, 0, errors); - - attr_ok && content_ok - } - - /// Checks if a specific attribute is present and its value matches. - fn validate_attribute( - &self, - attrs: &[crate::tree::Attribute], - matched: &mut [bool], - name_class: &NameClass, - value_pattern: &Pattern, - errors: &mut Vec<ValidationError>, - ) -> bool { - for (i, attr) in attrs.iter().enumerate() { - if matched[i] { - continue; - } - let attr_ns = attr.namespace.as_deref().unwrap_or(""); - if name_class.matches(attr_ns, &attr.name) { - matched[i] = true; - return self.validate_attribute_value( - &attr.value, - &attr.name, - value_pattern, - errors, - ); - } - } - - // Attribute not found. - errors.push(ValidationError { - message: format!("missing required attribute {name_class}"), - line: None, - column: None, - }); - false - } - - /// Validates an attribute value against a pattern. - fn validate_attribute_value( - &self, - value: &str, - attr_name: &str, - pattern: &Pattern, - errors: &mut Vec<ValidationError>, - ) -> bool { - match pattern { - Pattern::Value(expected) => { - if value == expected { - true - } else { - errors.push(ValidationError { - message: format!( - "attribute '{attr_name}' has value \"{value}\", \ - expected \"{expected}\"" - ), - line: None, - column: None, - }); - false - } - } - Pattern::Choice(a, b) => { - let mut tmp = Vec::new(); - if self.validate_attribute_value(value, attr_name, a, &mut tmp) { - return true; - } - self.validate_attribute_value(value, attr_name, b, errors) - } - Pattern::Data { datatype, library } => { - validate_datatype(value, attr_name, datatype, library, errors) - } - Pattern::List(inner) => { - let tokens: Vec<&str> = value.split_whitespace().collect(); - self.validate_list_tokens(&tokens, attr_name, inner, errors) - } - Pattern::Ref(ref_name) => { - if let Some(def) = self.defines.get(ref_name) { - self.validate_attribute_value(value, attr_name, def, errors) - } else { - errors.push(ValidationError { - message: format!("undefined reference: {ref_name}"), - line: None, - column: None, - }); - false - } - } - _ => true, // Be permissive for patterns we don't specifically handle. - } - } - - /// Validates a list of whitespace-separated tokens against a pattern. - fn validate_list_tokens( - &self, - tokens: &[&str], - attr_name: &str, - pattern: &Pattern, - errors: &mut Vec<ValidationError>, - ) -> bool { - match pattern { - Pattern::OneOrMore(inner) => { - if tokens.is_empty() { - errors.push(ValidationError { - message: format!( - "attribute '{attr_name}' list must have \ - at least one token" - ), - line: None, - column: None, - }); - return false; - } - tokens - .iter() - .all(|t| self.validate_attribute_value(t, attr_name, inner, errors)) - } - Pattern::ZeroOrMore(inner) => tokens - .iter() - .all(|t| self.validate_attribute_value(t, attr_name, inner, errors)), - _ => { - // Single-token list: validate the first token. - if let Some(t) = tokens.first() { - self.validate_attribute_value(t, attr_name, pattern, errors) - } else { - true - } - } - } - } - - /// Validates child nodes against a content pattern. - /// - /// Returns `true` if the children from `start` onwards match the pattern. - fn validate_children( - &self, - children: &[NodeId], - pattern: &Pattern, - start: usize, - errors: &mut Vec<ValidationError>, - ) -> bool { - // Filter to significant children (elements and non-whitespace text). - let significant: Vec<(usize, NodeId)> = children[start..] - .iter() - .enumerate() - .filter(|(_, &id)| match &self.doc.node(id).kind { - NodeKind::Text { content } => !content.trim().is_empty(), - NodeKind::Element { .. } | NodeKind::CData { .. } => true, - _ => false, // Skip comments, PIs - }) - .map(|(i, &id)| (start + i, id)) - .collect(); - - self.match_children(&significant, 0, pattern, errors) - } - - /// Recursive child-pattern matcher. Returns `true` if the significant - /// children from `pos` onwards match the given pattern. - #[allow(clippy::too_many_lines)] - fn match_children( - &self, - children: &[(usize, NodeId)], - pos: usize, - pattern: &Pattern, - errors: &mut Vec<ValidationError>, - ) -> bool { - match pattern { - Pattern::Empty => self.match_empty(children, pos, errors), - Pattern::NotAllowed => { - errors.push(ValidationError { - message: "content is not allowed here".to_string(), - line: None, - column: None, - }); - false - } - Pattern::Text => self.match_text(children, pos, errors), - Pattern::Element { - name, - pattern: inner, - } => self.match_element_child(children, pos, name, inner, errors), - Pattern::Group(a, b) => self.match_group(children, pos, a, b, errors), - Pattern::Choice(a, b) => { - let mut a_errors = Vec::new(); - if self.match_children(children, pos, a, &mut a_errors) { - return true; - } - let mut b_errors = Vec::new(); - if self.match_children(children, pos, b, &mut b_errors) { - return true; - } - errors.extend(b_errors); - false - } - Pattern::Optional(inner) => { - let mut tmp = Vec::new(); - if self.match_children(children, pos, inner, &mut tmp) { - return true; - } - // Optional: also allow zero matches. - self.match_children(children, pos, &Pattern::Empty, errors) - } - Pattern::ZeroOrMore(inner) => self.match_zero_or_more(children, pos, inner, errors), - Pattern::OneOrMore(inner) => self.match_one_or_more(children, pos, inner, errors), - Pattern::Interleave(a, b) => self.match_interleave(children, pos, a, b, errors), - Pattern::Mixed(inner) => { - let elements: Vec<(usize, NodeId)> = children[pos..] - .iter() - .filter(|(_, id)| matches!(self.doc.node(*id).kind, NodeKind::Element { .. })) - .copied() - .collect(); - self.match_children(&elements, 0, inner, errors) - } - Pattern::Value(expected) => { - let text = self.collect_children_text(children, pos); - if text.trim() == expected.trim() { - true - } else { - errors.push(ValidationError { - message: format!("expected value \"{expected}\", found \"{text}\""), - line: None, - column: None, - }); - false - } - } - Pattern::Data { datatype, library } => { - let text = self.collect_children_text(children, pos); - validate_datatype(text.trim(), "<text>", datatype, library, errors) - } - Pattern::List(inner) => { - let text = self.collect_children_text(children, pos); - let tokens: Vec<&str> = text.split_whitespace().collect(); - self.validate_list_tokens(&tokens, "<text>", inner, errors) - } - Pattern::Ref(ref_name) => { - if let Some(def) = self.defines.get(ref_name) { - self.match_children(children, pos, def, errors) - } else { - errors.push(ValidationError { - message: format!("undefined reference: {ref_name}"), - line: None, - column: None, - }); - false - } - } - Pattern::Attribute { .. } => { - // Attribute patterns in content position are already handled - // by the attribute validation pass. They match empty content. - pos >= children.len() - } - } - } - - /// Matches an `Empty` pattern against remaining children. - fn match_empty( - &self, - children: &[(usize, NodeId)], - pos: usize, - errors: &mut Vec<ValidationError>, - ) -> bool { - if pos >= children.len() { - return true; - } - for &(_, id) in &children[pos..] { - if let NodeKind::Text { content } = &self.doc.node(id).kind { - if content.trim().is_empty() { - continue; - } - } - let desc = self.node_description(id); - errors.push(ValidationError { - message: format!("unexpected content: {desc} (expected empty)"), - line: None, - column: None, - }); - return false; - } - true - } - - /// Matches a `Text` pattern against remaining children. - fn match_text( - &self, - children: &[(usize, NodeId)], - pos: usize, - errors: &mut Vec<ValidationError>, - ) -> bool { - for &(_, id) in &children[pos..] { - match &self.doc.node(id).kind { - NodeKind::Text { .. } | NodeKind::CData { .. } => {} - _ => { - let desc = self.node_description(id); - errors.push(ValidationError { - message: format!("unexpected {desc} (expected text)"), - line: None, - column: None, - }); - return false; - } - } - } - true - } - - /// Matches an element child pattern at a given position. - fn match_element_child( - &self, - children: &[(usize, NodeId)], - pos: usize, - name: &NameClass, - inner: &Pattern, - errors: &mut Vec<ValidationError>, - ) -> bool { - if pos >= children.len() { - errors.push(ValidationError { - message: format!("missing required element {name}"), - line: None, - column: None, - }); - return false; - } - let (_, child_id) = children[pos]; - if !self.validate_element(child_id, name, inner, errors) { - return false; - } - // Ensure no more children after this element. - if pos + 1 < children.len() { - for &(_, id) in &children[pos + 1..] { - let desc = self.node_description(id); - errors.push(ValidationError { - message: format!("unexpected content after element: {desc}"), - line: None, - column: None, - }); - } - return false; - } - true - } - - /// Matches a group pattern (sequential). Tries to find a split point - /// where the first pattern matches `children[pos..split]` and the second - /// matches `children[split..]`. - fn match_group( - &self, - children: &[(usize, NodeId)], - pos: usize, - a: &Pattern, - b: &Pattern, - errors: &mut Vec<ValidationError>, - ) -> bool { - // Try all possible split points. - for split in pos..=children.len() { - let slice_a = &children[..split]; - let mut a_errors = Vec::new(); - if self.match_children(slice_a, pos, a, &mut a_errors) { - let mut b_errors = Vec::new(); - if self.match_children(children, split, b, &mut b_errors) { - return true; - } - } - } - - // None of the split points worked. Generate an error. - let mut a_errors = Vec::new(); - if self.match_children(children, pos, a, &mut a_errors) { - // `a` matched some prefix but `b` couldn't match the rest. - let mut b_errors = Vec::new(); - let _ = self.match_children(children, children.len(), b, &mut b_errors); - errors.extend(b_errors); - } else { - errors.extend(a_errors); - } - false - } - - /// Matches zero or more occurrences of a pattern. - fn match_zero_or_more( - &self, - children: &[(usize, NodeId)], - pos: usize, - inner: &Pattern, - errors: &mut Vec<ValidationError>, - ) -> bool { - // Base case: all children consumed. - if pos >= children.len() { - return true; - } - - // Try to match one occurrence, then recurse for more. - for split in (pos + 1)..=children.len() { - let slice = &children[..split]; - let mut tmp = Vec::new(); - if self.match_children(slice, pos, inner, &mut tmp) { - let mut rest_errors = Vec::new(); - if self.match_zero_or_more(children, split, inner, &mut rest_errors) { - return true; - } - } - } - - // No match at all — check if remaining is empty (whitespace text). - self.match_children(children, pos, &Pattern::Empty, errors) - } - - /// Matches one or more occurrences of a pattern. - fn match_one_or_more( - &self, - children: &[(usize, NodeId)], - pos: usize, - inner: &Pattern, - errors: &mut Vec<ValidationError>, - ) -> bool { - // Must match at least once. - for split in (pos + 1)..=children.len() { - let slice = &children[..split]; - let mut tmp = Vec::new(); - if self.match_children(slice, pos, inner, &mut tmp) { - let mut rest_errors = Vec::new(); - if self.match_zero_or_more(children, split, inner, &mut rest_errors) { - return true; - } - } - } - - // Failed to match even once. - let _ = self.match_children(children, pos, inner, errors); - false - } - - /// Matches an interleave pattern — both sub-patterns must match but - /// in any order. Uses a subset-matching approach. - fn match_interleave( - &self, - children: &[(usize, NodeId)], - pos: usize, - a: &Pattern, - b: &Pattern, - errors: &mut Vec<ValidationError>, - ) -> bool { - let remaining = &children[pos..]; - if remaining.is_empty() { - // Both patterns must accept empty. - let mut tmp = Vec::new(); - let a_ok = self.match_children(&[], 0, a, &mut tmp); - let b_ok = self.match_children(&[], 0, b, &mut tmp); - if !a_ok || !b_ok { - errors.extend(tmp); - } - return a_ok && b_ok; - } - - // Try each possible partitioning of remaining children into - // two subsequences (maintaining order within each) that match - // a and b respectively. - // - // For small numbers of children, we use a bitmask approach where - // each bit indicates whether the child goes to partition A or B. - let n = remaining.len(); - if n > 20 { - // For very large child lists, fall back to a simpler heuristic: - // try matching a first, giving it greedy first pick, then b on rest. - return self.match_interleave_greedy(remaining, a, b, errors); - } - - let total = 1u32 << n; - for mask in 0..total { - let mut a_children: Vec<(usize, NodeId)> = Vec::new(); - let mut b_children: Vec<(usize, NodeId)> = Vec::new(); - for (i, &child) in remaining.iter().enumerate() { - if mask & (1 << i) != 0 { - a_children.push(child); - } else { - b_children.push(child); - } - } - let mut tmp = Vec::new(); - if self.match_children(&a_children, 0, a, &mut tmp) - && self.match_children(&b_children, 0, b, &mut tmp) - { - return true; - } - } - - errors.push(ValidationError { - message: "content does not match interleave pattern".to_string(), - line: None, - column: None, - }); - false - } - - /// Greedy interleave matching for large child lists. - fn match_interleave_greedy( - &self, - children: &[(usize, NodeId)], - a: &Pattern, - b: &Pattern, - errors: &mut Vec<ValidationError>, - ) -> bool { - let mut a_children: Vec<(usize, NodeId)> = Vec::new(); - let mut b_children: Vec<(usize, NodeId)> = Vec::new(); - - for &child in children { - let single = &[child]; - let mut tmp = Vec::new(); - if self.match_children(single, 0, a, &mut tmp) { - a_children.push(child); - } else { - b_children.push(child); - } - } - - let mut a_err = Vec::new(); - let mut b_err = Vec::new(); - let a_ok = self.match_children(&a_children, 0, a, &mut a_err); - let b_ok = self.match_children(&b_children, 0, b, &mut b_err); - - if !a_ok { - errors.extend(a_err); - } - if !b_ok { - errors.extend(b_err); - } - a_ok && b_ok - } - - /// Collects the text content of remaining children as a single string. - fn collect_children_text(&self, children: &[(usize, NodeId)], pos: usize) -> String { - let mut result = String::new(); - for &(_, id) in &children[pos..] { - match &self.doc.node(id).kind { - NodeKind::Text { content } | NodeKind::CData { content } => { - result.push_str(content); - } - _ => {} - } - } - result - } - - /// Returns a human-readable description of a node (for error messages). - fn node_description(&self, id: NodeId) -> String { - match &self.doc.node(id).kind { - NodeKind::Element { name, .. } => format!("element <{name}>"), - NodeKind::Text { content } => { - let truncated = if content.len() > 30 { - format!("\"{}...\"", &content[..30]) - } else { - format!("\"{content}\"") - }; - format!("text {truncated}") - } - NodeKind::CData { content } => { - let truncated = if content.len() > 30 { - format!("\"{}...\"", &content[..30]) - } else { - format!("\"{content}\"") - }; - format!("CDATA {truncated}") - } - NodeKind::Comment { .. } => "comment".to_string(), - NodeKind::ProcessingInstruction { target, .. } => { - format!("PI <?{target}?>") - } - _ => "node".to_string(), - } - } -} - -/// Very basic datatype validation (token, string, integer). -fn validate_datatype( - value: &str, - attr_name: &str, - datatype: &str, - _library: &str, - errors: &mut Vec<ValidationError>, -) -> bool { - match datatype { - "integer" | "int" | "long" | "short" | "byte" => { - if value.trim().parse::<i64>().is_ok() { - true - } else { - errors.push(ValidationError { - message: format!( - "attribute '{attr_name}' value \"{value}\" \ - is not a valid {datatype}" - ), - line: None, - column: None, - }); - false - } - } - "positiveInteger" | "nonNegativeInteger" => match value.trim().parse::<i64>() { - Ok(n) if n >= 0 => true, - _ => { - errors.push(ValidationError { - message: format!( - "attribute '{attr_name}' value \"{value}\" \ - is not a valid {datatype}" - ), - line: None, - column: None, - }); - false - } - }, - "boolean" => { - let v = value.trim(); - if v == "true" || v == "false" || v == "1" || v == "0" { - true - } else { - errors.push(ValidationError { - message: format!( - "attribute '{attr_name}' value \"{value}\" \ - is not a valid boolean" - ), - line: None, - column: None, - }); - false - } - } - _ => true, // Unknown datatypes are accepted. - } -} - -/// Checks whether an attribute is an `xmlns` declaration (which are not -/// validated by `RelaxNG`). -fn is_xmlns_attribute(attr: &crate::tree::Attribute) -> bool { - attr.name == "xmlns" - || attr.prefix.as_deref() == Some("xmlns") - || attr.namespace.as_deref() == Some("http://www.w3.org/2000/xmlns/") -} - -/// Splits a pattern into attribute patterns and the remaining content pattern. -/// -/// This walks the pattern tree and extracts all `Attribute` patterns, -/// returning them separately from the content pattern (which has the -/// attribute patterns replaced with `Empty`). -fn split_attributes(pattern: &Pattern) -> (Vec<Pattern>, Pattern) { - let mut attrs = Vec::new(); - let content = extract_attrs(pattern, &mut attrs); - (attrs, content) -} - -/// Recursively extracts attribute patterns from a pattern tree. -fn extract_attrs(pattern: &Pattern, attrs: &mut Vec<Pattern>) -> Pattern { - match pattern { - Pattern::Attribute { .. } => { - attrs.push(pattern.clone()); - Pattern::Empty - } - Pattern::Group(a, b) => { - let a2 = extract_attrs(a, attrs); - let b2 = extract_attrs(b, attrs); - match (&a2, &b2) { - (Pattern::Empty, _) => b2, - (_, Pattern::Empty) => a2, - _ => Pattern::Group(Box::new(a2), Box::new(b2)), - } - } - Pattern::Interleave(a, b) => { - let a2 = extract_attrs(a, attrs); - let b2 = extract_attrs(b, attrs); - match (&a2, &b2) { - (Pattern::Empty, _) => b2, - (_, Pattern::Empty) => a2, - _ => Pattern::Interleave(Box::new(a2), Box::new(b2)), - } - } - Pattern::Optional(inner) => { - if matches!(inner.as_ref(), Pattern::Attribute { .. }) { - // Optional attribute: still extract it but mark it optional - // by wrapping in Optional. - attrs.push(Pattern::Optional(inner.clone())); - Pattern::Empty - } else { - let inner2 = extract_attrs(inner, attrs); - Pattern::Optional(Box::new(inner2)) - } - } - _ => pattern.clone(), - } -} - -/// Checks whether a pattern tree contains a wildcard attribute pattern -/// (attribute with `AnyName` name class). -fn has_wildcard_attribute(pattern: &Pattern) -> bool { - match pattern { - Pattern::Attribute { - name: NameClass::AnyName, - .. - } => true, - Pattern::Group(a, b) | Pattern::Interleave(a, b) | Pattern::Choice(a, b) => { - has_wildcard_attribute(a) || has_wildcard_attribute(b) - } - Pattern::Optional(p) - | Pattern::ZeroOrMore(p) - | Pattern::OneOrMore(p) - | Pattern::Mixed(p) => has_wildcard_attribute(p), - _ => false, - } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - - // --- Name class matching tests --- - - #[test] - fn test_name_class_specific_name_matches() { - let nc = NameClass::Name { - ns: String::new(), - local: "foo".to_string(), - }; - assert!(nc.matches("", "foo")); - assert!(!nc.matches("", "bar")); - } - - #[test] - fn test_name_class_specific_name_with_ns() { - let nc = NameClass::Name { - ns: "http://example.com".to_string(), - local: "foo".to_string(), - }; - assert!(nc.matches("http://example.com", "foo")); - assert!(!nc.matches("", "foo")); - assert!(!nc.matches("http://example.com", "bar")); - } - - #[test] - fn test_name_class_any_name() { - let nc = NameClass::AnyName; - assert!(nc.matches("", "anything")); - assert!(nc.matches("http://example.com", "anything")); - } - - #[test] - fn test_name_class_any_name_except() { - let nc = NameClass::AnyNameExcept(Box::new(NameClass::Name { - ns: String::new(), - local: "secret".to_string(), - })); - assert!(nc.matches("", "foo")); - assert!(!nc.matches("", "secret")); - } - - #[test] - fn test_name_class_ns_name() { - let nc = NameClass::NsName { - ns: "http://example.com".to_string(), - }; - assert!(nc.matches("http://example.com", "anything")); - assert!(!nc.matches("http://other.com", "anything")); - } - - #[test] - fn test_name_class_ns_name_except() { - let nc = NameClass::NsNameExcept { - ns: "http://example.com".to_string(), - except: Box::new(NameClass::Name { - ns: "http://example.com".to_string(), - local: "secret".to_string(), - }), - }; - assert!(nc.matches("http://example.com", "foo")); - assert!(!nc.matches("http://example.com", "secret")); - assert!(!nc.matches("http://other.com", "foo")); - } - - #[test] - fn test_name_class_choice() { - let nc = NameClass::Choice( - Box::new(NameClass::Name { - ns: String::new(), - local: "a".to_string(), - }), - Box::new(NameClass::Name { - ns: String::new(), - local: "b".to_string(), - }), - ); - assert!(nc.matches("", "a")); - assert!(nc.matches("", "b")); - assert!(!nc.matches("", "c")); - } - - // --- Schema parsing tests --- - - #[test] - fn test_parse_simple_element_schema() { - let schema_xml = r#" - <element name="greeting" xmlns="http://relaxng.org/ns/structure/1.0"> - <text/> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - assert!(matches!(schema.start, Pattern::Element { .. })); - assert!(schema.defines.is_empty()); - } - - #[test] - fn test_parse_grammar_with_start_and_define() { - let schema_xml = r#" - <grammar xmlns="http://relaxng.org/ns/structure/1.0"> - <start> - <ref name="root"/> - </start> - <define name="root"> - <element name="root"> - <text/> - </element> - </define> - </grammar> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - assert!(matches!(schema.start, Pattern::Ref(ref name) if name == "root")); - assert!(schema.defines.contains_key("root")); - } - - #[test] - fn test_parse_element_with_attributes() { - let schema_xml = r#" - <element name="person" xmlns="http://relaxng.org/ns/structure/1.0"> - <attribute name="id"/> - <text/> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let Pattern::Element { pattern, .. } = &schema.start else { - panic!("expected Element pattern, got {:?}", schema.start); - }; - // Should be Group(Attribute, Text) - assert!(matches!(pattern.as_ref(), Pattern::Group(_, _))); - } - - #[test] - fn test_parse_choice_pattern() { - let schema_xml = r#" - <element name="value" xmlns="http://relaxng.org/ns/structure/1.0"> - <choice> - <element name="a"><text/></element> - <element name="b"><text/></element> - </choice> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let Pattern::Element { pattern, .. } = &schema.start else { - panic!("expected Element pattern, got {:?}", schema.start); - }; - assert!(matches!(pattern.as_ref(), Pattern::Choice(_, _))); - } - - #[test] - fn test_parse_zero_or_more() { - let schema_xml = r#" - <element name="list" xmlns="http://relaxng.org/ns/structure/1.0"> - <zeroOrMore> - <element name="item"><text/></element> - </zeroOrMore> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let Pattern::Element { pattern, .. } = &schema.start else { - panic!("expected Element pattern, got {:?}", schema.start); - }; - assert!(matches!(pattern.as_ref(), Pattern::ZeroOrMore(_))); - } - - #[test] - fn test_parse_one_or_more() { - let schema_xml = r#" - <element name="list" xmlns="http://relaxng.org/ns/structure/1.0"> - <oneOrMore> - <element name="item"><text/></element> - </oneOrMore> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let Pattern::Element { pattern, .. } = &schema.start else { - panic!("expected Element pattern, got {:?}", schema.start); - }; - assert!(matches!(pattern.as_ref(), Pattern::OneOrMore(_))); - } - - #[test] - fn test_parse_optional_pattern() { - let schema_xml = r#" - <element name="doc" xmlns="http://relaxng.org/ns/structure/1.0"> - <optional> - <attribute name="lang"/> - </optional> - <text/> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let Pattern::Element { pattern, .. } = &schema.start else { - panic!("expected Element pattern, got {:?}", schema.start); - }; - assert!(matches!(pattern.as_ref(), Pattern::Group(_, _))); - } - - #[test] - fn test_parse_interleave_pattern() { - let schema_xml = r#" - <element name="doc" xmlns="http://relaxng.org/ns/structure/1.0"> - <interleave> - <element name="a"><text/></element> - <element name="b"><text/></element> - </interleave> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let Pattern::Element { pattern, .. } = &schema.start else { - panic!("expected Element pattern, got {:?}", schema.start); - }; - assert!(matches!(pattern.as_ref(), Pattern::Interleave(_, _))); - } - - #[test] - fn test_parse_value_pattern() { - let schema_xml = r#" - <element name="status" xmlns="http://relaxng.org/ns/structure/1.0"> - <value>active</value> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let Pattern::Element { pattern, .. } = &schema.start else { - panic!("expected Element pattern, got {:?}", schema.start); - }; - assert!(matches!(pattern.as_ref(), Pattern::Value(v) if v == "active")); - } - - #[test] - fn test_parse_data_pattern() { - let schema_xml = r#" - <element name="count" xmlns="http://relaxng.org/ns/structure/1.0"> - <data type="integer"/> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let Pattern::Element { pattern, .. } = &schema.start else { - panic!("expected Element pattern, got {:?}", schema.start); - }; - assert!( - matches!(pattern.as_ref(), Pattern::Data { datatype, .. } if datatype == "integer") - ); - } - - // --- Validation tests --- - - #[test] - fn test_validate_simple_element_with_text() { - let schema_xml = r#" - <element name="greeting" xmlns="http://relaxng.org/ns/structure/1.0"> - <text/> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let doc = Document::parse_str("<greeting>Hello!</greeting>").unwrap(); - let result = validate(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - } - - #[test] - fn test_validate_wrong_root_element() { - let schema_xml = r#" - <element name="greeting" xmlns="http://relaxng.org/ns/structure/1.0"> - <text/> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let doc = Document::parse_str("<salutation>Hi</salutation>").unwrap(); - let result = validate(&doc, &schema); - assert!(!result.is_valid); - assert!(!result.errors.is_empty()); - } - - #[test] - fn test_validate_missing_required_attribute() { - let schema_xml = r#" - <element name="person" xmlns="http://relaxng.org/ns/structure/1.0"> - <attribute name="id"/> - <text/> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let doc = Document::parse_str("<person>John</person>").unwrap(); - let result = validate(&doc, &schema); - assert!(!result.is_valid); - assert!( - result - .errors - .iter() - .any(|e| e.message.contains("attribute")), - "expected attribute error, got: {:?}", - result.errors - ); - } - - #[test] - fn test_validate_element_with_attribute() { - let schema_xml = r#" - <element name="person" xmlns="http://relaxng.org/ns/structure/1.0"> - <attribute name="id"/> - <text/> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let doc = Document::parse_str(r#"<person id="42">John</person>"#).unwrap(); - let result = validate(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - } - - #[test] - fn test_validate_unexpected_attribute() { - let schema_xml = r#" - <element name="item" xmlns="http://relaxng.org/ns/structure/1.0"> - <empty/> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let doc = Document::parse_str(r#"<item extra="oops"/>"#).unwrap(); - let result = validate(&doc, &schema); - assert!(!result.is_valid); - assert!( - result - .errors - .iter() - .any(|e| e.message.contains("unexpected attribute")), - "expected unexpected attribute error, got: {:?}", - result.errors - ); - } - - #[test] - fn test_validate_choice_first_alternative() { - let schema_xml = r#" - <element name="value" xmlns="http://relaxng.org/ns/structure/1.0"> - <choice> - <element name="a"><text/></element> - <element name="b"><text/></element> - </choice> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let doc = Document::parse_str("<value><a>hello</a></value>").unwrap(); - let result = validate(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - } - - #[test] - fn test_validate_choice_second_alternative() { - let schema_xml = r#" - <element name="value" xmlns="http://relaxng.org/ns/structure/1.0"> - <choice> - <element name="a"><text/></element> - <element name="b"><text/></element> - </choice> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let doc = Document::parse_str("<value><b>hello</b></value>").unwrap(); - let result = validate(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - } - - #[test] - fn test_validate_choice_invalid() { - let schema_xml = r#" - <element name="value" xmlns="http://relaxng.org/ns/structure/1.0"> - <choice> - <element name="a"><text/></element> - <element name="b"><text/></element> - </choice> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let doc = Document::parse_str("<value><c>hello</c></value>").unwrap(); - let result = validate(&doc, &schema); - assert!(!result.is_valid); - } - - #[test] - fn test_validate_zero_or_more_empty() { - let schema_xml = r#" - <element name="list" xmlns="http://relaxng.org/ns/structure/1.0"> - <zeroOrMore> - <element name="item"><text/></element> - </zeroOrMore> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let doc = Document::parse_str("<list/>").unwrap(); - let result = validate(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - } - - #[test] - fn test_validate_zero_or_more_multiple() { - let schema_xml = r#" - <element name="list" xmlns="http://relaxng.org/ns/structure/1.0"> - <zeroOrMore> - <element name="item"><text/></element> - </zeroOrMore> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let doc = - Document::parse_str("<list><item>a</item><item>b</item><item>c</item></list>").unwrap(); - let result = validate(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - } - - #[test] - fn test_validate_one_or_more_empty_fails() { - let schema_xml = r#" - <element name="list" xmlns="http://relaxng.org/ns/structure/1.0"> - <oneOrMore> - <element name="item"><text/></element> - </oneOrMore> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let doc = Document::parse_str("<list/>").unwrap(); - let result = validate(&doc, &schema); - assert!(!result.is_valid); - } - - #[test] - fn test_validate_one_or_more_with_items() { - let schema_xml = r#" - <element name="list" xmlns="http://relaxng.org/ns/structure/1.0"> - <oneOrMore> - <element name="item"><text/></element> - </oneOrMore> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let doc = Document::parse_str("<list><item>a</item><item>b</item></list>").unwrap(); - let result = validate(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - } - - #[test] - fn test_validate_optional_present() { - let schema_xml = r#" - <element name="doc" xmlns="http://relaxng.org/ns/structure/1.0"> - <optional> - <attribute name="lang"/> - </optional> - <text/> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let doc = Document::parse_str(r#"<doc lang="en">Hello</doc>"#).unwrap(); - let result = validate(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - } - - #[test] - fn test_validate_optional_absent() { - let schema_xml = r#" - <element name="doc" xmlns="http://relaxng.org/ns/structure/1.0"> - <optional> - <attribute name="lang"/> - </optional> - <text/> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let doc = Document::parse_str("<doc>Hello</doc>").unwrap(); - let result = validate(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - } - - #[test] - fn test_validate_interleave_any_order() { - let schema_xml = r#" - <element name="doc" xmlns="http://relaxng.org/ns/structure/1.0"> - <interleave> - <element name="a"><text/></element> - <element name="b"><text/></element> - </interleave> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - - // Order 1: a then b - let doc1 = Document::parse_str("<doc><a>1</a><b>2</b></doc>").unwrap(); - let r1 = validate(&doc1, &schema); - assert!(r1.is_valid, "a,b order failed: {:?}", r1.errors); - - // Order 2: b then a - let doc2 = Document::parse_str("<doc><b>2</b><a>1</a></doc>").unwrap(); - let r2 = validate(&doc2, &schema); - assert!(r2.is_valid, "b,a order failed: {:?}", r2.errors); - } - - #[test] - fn test_validate_ref_define_resolution() { - let schema_xml = r#" - <grammar xmlns="http://relaxng.org/ns/structure/1.0"> - <start> - <ref name="root"/> - </start> - <define name="root"> - <element name="root"> - <ref name="content"/> - </element> - </define> - <define name="content"> - <element name="child"><text/></element> - </define> - </grammar> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let doc = Document::parse_str("<root><child>hello</child></root>").unwrap(); - let result = validate(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - } - - #[test] - fn test_validate_nested_elements() { - let schema_xml = r#" - <element name="root" xmlns="http://relaxng.org/ns/structure/1.0"> - <element name="parent"> - <element name="child"> - <text/> - </element> - </element> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let doc = Document::parse_str("<root><parent><child>text</child></parent></root>").unwrap(); - let result = validate(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - } - - #[test] - fn test_validate_value_match() { - let schema_xml = r#" - <element name="status" xmlns="http://relaxng.org/ns/structure/1.0"> - <value>active</value> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - - let doc1 = Document::parse_str("<status>active</status>").unwrap(); - let r1 = validate(&doc1, &schema); - assert!(r1.is_valid, "errors: {:?}", r1.errors); - - let doc2 = Document::parse_str("<status>inactive</status>").unwrap(); - let r2 = validate(&doc2, &schema); - assert!(!r2.is_valid); - } - - #[test] - fn test_validate_missing_element() { - let schema_xml = r#" - <element name="root" xmlns="http://relaxng.org/ns/structure/1.0"> - <element name="required"><text/></element> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let doc = Document::parse_str("<root/>").unwrap(); - let result = validate(&doc, &schema); - assert!(!result.is_valid); - assert!( - result.errors.iter().any(|e| e.message.contains("missing")), - "expected missing element error, got: {:?}", - result.errors - ); - } - - #[test] - fn test_validate_unexpected_element() { - let schema_xml = r#" - <element name="root" xmlns="http://relaxng.org/ns/structure/1.0"> - <empty/> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let doc = Document::parse_str("<root><surprise>oops</surprise></root>").unwrap(); - let result = validate(&doc, &schema); - assert!(!result.is_valid); - } - - #[test] - fn test_validate_empty_element() { - let schema_xml = r#" - <element name="br" xmlns="http://relaxng.org/ns/structure/1.0"> - <empty/> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let doc = Document::parse_str("<br/>").unwrap(); - let result = validate(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - } - - #[test] - fn test_validate_attribute_value_mismatch() { - let schema_xml = r#" - <element name="item" xmlns="http://relaxng.org/ns/structure/1.0"> - <attribute name="type"> - <value>book</value> - </attribute> - <text/> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - - // Correct value. - let doc1 = Document::parse_str(r#"<item type="book">Title</item>"#).unwrap(); - let r1 = validate(&doc1, &schema); - assert!(r1.is_valid, "errors: {:?}", r1.errors); - - // Wrong value. - let doc2 = Document::parse_str(r#"<item type="dvd">Title</item>"#).unwrap(); - let r2 = validate(&doc2, &schema); - assert!(!r2.is_valid); - } - - #[test] - fn test_validate_no_root_element() { - let schema_xml = r#" - <element name="root" xmlns="http://relaxng.org/ns/structure/1.0"> - <text/> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - - let doc = Document::new(); - let result = validate(&doc, &schema); - assert!(!result.is_valid); - assert!(result - .errors - .iter() - .any(|e| e.message.contains("no root element")),); - } - - #[test] - fn test_validate_sequence_of_elements() { - let schema_xml = r#" - <element name="root" xmlns="http://relaxng.org/ns/structure/1.0"> - <element name="first"><text/></element> - <element name="second"><text/></element> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - - let doc = Document::parse_str("<root><first>a</first><second>b</second></root>").unwrap(); - let result = validate(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - } - - #[test] - fn test_validate_sequence_wrong_order() { - let schema_xml = r#" - <element name="root" xmlns="http://relaxng.org/ns/structure/1.0"> - <element name="first"><text/></element> - <element name="second"><text/></element> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - - let doc = Document::parse_str("<root><second>b</second><first>a</first></root>").unwrap(); - let result = validate(&doc, &schema); - assert!(!result.is_valid); - } - - #[test] - fn test_pattern_display() { - assert_eq!(Pattern::Empty.to_string(), "empty"); - assert_eq!(Pattern::Text.to_string(), "text"); - assert_eq!(Pattern::NotAllowed.to_string(), "notAllowed"); - assert_eq!( - Pattern::Element { - name: NameClass::Name { - ns: String::new(), - local: "div".to_string(), - }, - pattern: Box::new(Pattern::Empty), - } - .to_string(), - "element div" - ); - } - - #[test] - fn test_name_class_display() { - assert_eq!(NameClass::AnyName.to_string(), "*"); - assert_eq!( - NameClass::Name { - ns: String::new(), - local: "foo".to_string(), - } - .to_string(), - "foo" - ); - assert_eq!( - NameClass::Name { - ns: "http://example.com".to_string(), - local: "foo".to_string(), - } - .to_string(), - "{http://example.com}foo" - ); - } - - #[test] - fn test_schema_parse_error_display() { - let err = SchemaParseError { - message: "test error".to_string(), - }; - assert_eq!(err.to_string(), "RelaxNG schema error: test error"); - } - - #[test] - fn test_parse_complex_grammar_with_cross_refs() { - let schema_xml = r#" - <grammar xmlns="http://relaxng.org/ns/structure/1.0"> - <start> - <element name="addressBook"> - <zeroOrMore> - <ref name="cardContent"/> - </zeroOrMore> - </element> - </start> - <define name="cardContent"> - <element name="card"> - <ref name="cardFields"/> - </element> - </define> - <define name="cardFields"> - <element name="name"><text/></element> - <element name="email"><text/></element> - </define> - </grammar> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - assert!(schema.defines.contains_key("cardContent")); - assert!(schema.defines.contains_key("cardFields")); - - let doc = Document::parse_str( - "<addressBook>\ - <card><name>Alice</name><email>alice@example.com</email></card>\ - <card><name>Bob</name><email>bob@example.com</email></card>\ - </addressBook>", - ) - .unwrap(); - let result = validate(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - } - - #[test] - fn test_validate_mixed_content() { - let schema_xml = r#" - <element name="p" xmlns="http://relaxng.org/ns/structure/1.0"> - <mixed> - <zeroOrMore> - <element name="b"><text/></element> - </zeroOrMore> - </mixed> - </element> - "#; - let schema = parse_relaxng(schema_xml).unwrap(); - let doc = Document::parse_str("<p>Hello <b>world</b> and more</p>").unwrap(); - let result = validate(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - } -} diff --git a/browser/vendor/xmloxide/src/validation/schematron.rs b/browser/vendor/xmloxide/src/validation/schematron.rs deleted file mode 100644 index 500ff7b80..000000000 --- a/browser/vendor/xmloxide/src/validation/schematron.rs +++ /dev/null @@ -1,1951 +0,0 @@ -//! ISO Schematron validation for XML documents. -//! -//! This module implements a subset of the ISO Schematron specification -//! (ISO/IEC 19757-3) for rule-based XML document validation. Schematron -//! schemas express constraints as `XPath` assertions evaluated against -//! selected context nodes, complementing grammar-based schemas like DTD, -//! `RelaxNG`, and XSD. -//! -//! # Architecture -//! -//! The implementation is split into three layers: -//! -//! 1. **Data model** ([`SchematronSchema`], [`SchematronPattern`], -//! [`SchematronRule`], [`SchematronCheck`]) — the parsed schema -//! representation. -//! 2. **Schema parser** ([`parse_schematron`]) — reads a Schematron XML -//! schema and produces a `SchematronSchema`. -//! 3. **Validator** ([`validate_schematron`], [`validate_schematron_with_phase`]) -//! — evaluates assertions against a document tree using the `XPath` engine. -//! -//! # Examples -//! -//! ``` -//! use xmloxide::Document; -//! use xmloxide::validation::schematron::{parse_schematron, validate_schematron}; -//! -//! let schema_xml = r#" -//! <schema xmlns="http://purl.oclc.org/dml/schematron"> -//! <pattern> -//! <rule context="/root"> -//! <assert test="child">root must have a child element</assert> -//! </rule> -//! </pattern> -//! </schema> -//! "#; -//! -//! let schema = parse_schematron(schema_xml).unwrap(); -//! let doc = Document::parse_str("<root><child/></root>").unwrap(); -//! let result = validate_schematron(&doc, &schema); -//! assert!(result.is_valid); -//! ``` -//! -//! # Limitations -//! -//! - Namespace-prefixed `XPath` name tests (e.g., `//inv:invoice`) do not -//! match because the `XPath` evaluator compares against local names. -//! Unprefixed names work. Workaround: use `local-name()`. -//! - Abstract patterns and `<sch:extends>` are not supported. -//! - `xsl:key` and XSLT-specific features are not supported. -//! - Variable forward references are not supported (evaluated in document order). - -use std::collections::{HashMap, HashSet}; - -use crate::tree::{Document, NodeId, NodeKind}; -use crate::validation::{ValidationError, ValidationResult}; -use crate::xpath; -use crate::xpath::eval::XPathContext; -use crate::xpath::types::{XPathError, XPathNode, XPathValue}; - -// --------------------------------------------------------------------------- -// Data model -// --------------------------------------------------------------------------- - -/// A parsed ISO Schematron schema. -/// -/// Contains patterns (each with rules and assertions), namespace bindings, -/// schema-level variables, and optional phase definitions. -#[derive(Debug, Clone)] -pub struct SchematronSchema { - /// Namespace bindings from `<sch:ns>` elements. - pub namespaces: Vec<NamespaceBinding>, - /// Schema-level `<sch:let>` variable bindings. - pub variables: Vec<LetBinding>, - /// Patterns containing rules and assertions. - pub patterns: Vec<SchematronPattern>, - /// Named phases that activate subsets of patterns. - pub phases: HashMap<String, Phase>, - /// The default phase (`defaultPhase` attribute on the root element). - pub default_phase: Option<String>, -} - -/// A namespace binding declared via `<sch:ns prefix="..." uri="..."/>`. -#[derive(Debug, Clone)] -pub struct NamespaceBinding { - /// The namespace prefix. - pub prefix: String, - /// The namespace URI. - pub uri: String, -} - -/// A `let` variable binding declared via `<sch:let name="..." value="..."/>`. -#[derive(Debug, Clone)] -pub struct LetBinding { - /// The variable name (referenced as `$name` in `XPath` expressions). - pub name: String, - /// The `XPath` expression whose result is bound to the variable. - pub value: String, -} - -/// A pattern containing rules, with optional id and pattern-level variables. -#[derive(Debug, Clone)] -pub struct SchematronPattern { - /// Optional pattern identifier (used by phases to activate subsets). - pub id: Option<String>, - /// Whether this is an abstract pattern (template for `is-a` instantiation). - pub is_abstract: bool, - /// Reference to an abstract pattern id (instantiates the abstract pattern - /// with parameter substitutions). - pub is_a: Option<String>, - /// Parameter bindings for `is-a` instantiation (`<sch:param>`). - pub params: Vec<(String, String)>, - /// Pattern-level `<sch:let>` variable bindings. - pub variables: Vec<LetBinding>, - /// Rules within this pattern. - pub rules: Vec<SchematronRule>, -} - -/// A rule that selects context nodes and applies checks to them. -#[derive(Debug, Clone)] -pub struct SchematronRule { - /// `XPath` expression selecting context nodes. - pub context: String, - /// Rule-level `<sch:let>` variable bindings. - pub variables: Vec<LetBinding>, - /// Assertions and reports to evaluate at each context node. - pub checks: Vec<SchematronCheck>, -} - -/// An individual assertion or report within a rule. -#[derive(Debug, Clone)] -pub enum SchematronCheck { - /// An assertion: if `test` evaluates to false at the context node, - /// a validation error is raised with `message`. - Assert { - /// `XPath` boolean expression. - test: String, - /// Human-readable message parts (may include `<sch:value-of>`). - message: Vec<MessagePart>, - }, - /// A report: if `test` evaluates to true at the context node, - /// a validation warning is raised with `message`. - Report { - /// `XPath` boolean expression. - test: String, - /// Human-readable message parts (may include `<sch:value-of>`). - message: Vec<MessagePart>, - }, -} - -/// A segment of a Schematron message (plain text or interpolated value). -#[derive(Debug, Clone)] -pub enum MessagePart { - /// Literal text. - Text(String), - /// An `XPath` expression whose string value is interpolated via - /// `<sch:value-of select="..."/>`. - ValueOf { - /// The `XPath` `select` expression. - select: String, - }, -} - -/// A named phase that activates a subset of patterns. -#[derive(Debug, Clone)] -pub struct Phase { - /// The phase identifier. - pub id: String, - /// Pattern ids activated by this phase. - pub active_patterns: Vec<String>, -} - -// --------------------------------------------------------------------------- -// Schematron namespace constants -// --------------------------------------------------------------------------- - -/// ISO Schematron namespace URI. -const SCH_NS_ISO: &str = "http://purl.oclc.org/dml/schematron"; - -/// Classic Schematron 1.5 namespace URI. -const SCH_NS_CLASSIC: &str = "http://www.ascc.net/xml/schematron"; - -// --------------------------------------------------------------------------- -// Schema parser -// --------------------------------------------------------------------------- - -/// Parses a Schematron schema from an XML string. -/// -/// Supports both the ISO namespace (`http://purl.oclc.org/dml/schematron`) -/// and the classic 1.5 namespace (`http://www.ascc.net/xml/schematron`). -/// The schema can also use the `sch:` prefix convention with no namespace. -/// -/// # Errors -/// -/// Returns [`ValidationError`] if the XML cannot be parsed or the schema -/// structure is invalid (e.g., a rule missing its `context` attribute). -/// -/// # Examples -/// -/// ``` -/// use xmloxide::validation::schematron::parse_schematron; -/// -/// let schema = parse_schematron(r#" -/// <schema xmlns="http://purl.oclc.org/dml/schematron"> -/// <pattern> -/// <rule context="/*"> -/// <assert test="true()">always passes</assert> -/// </rule> -/// </pattern> -/// </schema> -/// "#).unwrap(); -/// assert_eq!(schema.patterns.len(), 1); -/// ``` -pub fn parse_schematron(schema_xml: &str) -> Result<SchematronSchema, ValidationError> { - let doc = Document::parse_str(schema_xml).map_err(|e| ValidationError { - message: format!("failed to parse Schematron schema XML: {e}"), - line: None, - column: None, - })?; - - let root = doc.root_element().ok_or_else(|| ValidationError { - message: "Schematron schema has no root element".to_string(), - line: None, - column: None, - })?; - - let root_name = doc.node_name(root).unwrap_or(""); - if !is_sch_element(root_name, "schema") { - return Err(ValidationError { - message: format!("expected <schema> root element, found <{root_name}>"), - line: None, - column: None, - }); - } - - let root_ns = doc.node_namespace(root).unwrap_or(""); - let ns_mode = detect_ns_mode(root_ns, root_name); - - let default_phase = doc.attribute(root, "defaultPhase").map(String::from); - - let mut namespaces = Vec::new(); - let mut variables = Vec::new(); - let mut patterns = Vec::new(); - let mut phases = HashMap::new(); - - for child in doc.children(root) { - if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { - continue; - } - let name = doc.node_name(child).unwrap_or(""); - let child_ns = doc.node_namespace(child).unwrap_or(""); - - if !is_sch_name_in_mode(&ns_mode, child_ns) { - continue; - } - - let local = sch_local_name(name); - match local { - "ns" => { - if let (Some(prefix), Some(uri)) = - (doc.attribute(child, "prefix"), doc.attribute(child, "uri")) - { - namespaces.push(NamespaceBinding { - prefix: prefix.to_owned(), - uri: uri.to_owned(), - }); - } - } - "let" => { - if let Some(binding) = parse_let_binding(&doc, child) { - variables.push(binding); - } - } - "pattern" => { - patterns.push(parse_pattern(&doc, &ns_mode, child)?); - } - "phase" => { - if let Some(phase) = parse_phase(&doc, &ns_mode, child) { - phases.insert(phase.id.clone(), phase); - } - } - _ => {} - } - } - - // Resolve abstract pattern instantiations (is-a references) - let patterns = resolve_abstract_patterns(patterns); - - Ok(SchematronSchema { - namespaces, - variables, - patterns, - phases, - default_phase, - }) -} - -/// Resolves `is-a` references by copying rules from abstract patterns -/// and substituting `$param` placeholders in context and test expressions. -fn resolve_abstract_patterns(patterns: Vec<SchematronPattern>) -> Vec<SchematronPattern> { - // Collect abstract patterns by id (cloned so we can move patterns below) - let abstract_map: HashMap<String, SchematronPattern> = patterns - .iter() - .filter(|p| p.is_abstract) - .filter_map(|p| p.id.as_ref().map(|id| (id.clone(), p.clone()))) - .collect(); - - patterns - .into_iter() - .filter(|p| !p.is_abstract) // Exclude abstract patterns from validation - .map(|mut p| { - if let Some(ref abstract_id) = p.is_a { - if let Some(abstract_pat) = abstract_map.get(abstract_id) { - // Copy rules from abstract pattern, substituting params - p.rules = abstract_pat - .rules - .iter() - .map(|rule| substitute_rule_params(rule, &p.params)) - .collect(); - // Also inherit variables from abstract pattern - let mut combined_vars = abstract_pat.variables.clone(); - combined_vars.extend(p.variables.clone()); - p.variables = combined_vars; - } - } - p - }) - .collect() -} - -/// Substitutes `$param_name` placeholders in a rule's context and test -/// expressions with the corresponding parameter values. -fn substitute_rule_params(rule: &SchematronRule, params: &[(String, String)]) -> SchematronRule { - SchematronRule { - context: substitute_params(&rule.context, params), - variables: rule - .variables - .iter() - .map(|v| LetBinding { - name: v.name.clone(), - value: substitute_params(&v.value, params), - }) - .collect(), - checks: rule - .checks - .iter() - .map(|check| match check { - SchematronCheck::Assert { test, message } => SchematronCheck::Assert { - test: substitute_params(test, params), - message: message.clone(), - }, - SchematronCheck::Report { test, message } => SchematronCheck::Report { - test: substitute_params(test, params), - message: message.clone(), - }, - }) - .collect(), - } -} - -/// Replaces `$name` placeholders in `text` with parameter values. -fn substitute_params(text: &str, params: &[(String, String)]) -> String { - let mut result = text.to_string(); - for (name, value) in params { - let placeholder = format!("${name}"); - result = result.replace(&placeholder, value); - } - result -} - -/// Namespace detection mode for parsing Schematron elements. -#[derive(Debug, Clone)] -enum NsMode { - /// Elements are in the ISO namespace. - Iso, - /// Elements are in the classic 1.5 namespace. - Classic, - /// Elements use `sch:` prefix with no namespace (or unrecognized namespace). - Prefix, -} - -/// Detects which namespace mode to use based on the root element. -fn detect_ns_mode(ns: &str, _name: &str) -> NsMode { - match ns { - SCH_NS_ISO => NsMode::Iso, - SCH_NS_CLASSIC => NsMode::Classic, - _ => NsMode::Prefix, - } -} - -/// Checks if a given element name matches a Schematron local name. -fn is_sch_element(name: &str, local: &str) -> bool { - name == local || name == format!("sch:{local}") || name.ends_with(&format!(":{local}")) -} - -/// Checks if a child element is a Schematron element in the detected mode. -fn is_sch_name_in_mode(mode: &NsMode, ns: &str) -> bool { - match mode { - NsMode::Iso => ns == SCH_NS_ISO, - NsMode::Classic => ns == SCH_NS_CLASSIC, - NsMode::Prefix => { - // Accept sch: prefix or bare names in schema context - ns == SCH_NS_ISO || ns == SCH_NS_CLASSIC || ns.is_empty() - } - } -} - -/// Extracts the local name from a potentially prefixed element name. -fn sch_local_name(name: &str) -> &str { - name.rsplit(':').next().unwrap_or(name) -} - -/// Parses a `<sch:let>` binding. -fn parse_let_binding(doc: &Document, node: NodeId) -> Option<LetBinding> { - let name = doc.attribute(node, "name")?; - let value = doc.attribute(node, "value").unwrap_or(""); - Some(LetBinding { - name: name.to_owned(), - value: value.to_owned(), - }) -} - -/// Parses a `<sch:pattern>` element. -fn parse_pattern( - doc: &Document, - ns_mode: &NsMode, - node: NodeId, -) -> Result<SchematronPattern, ValidationError> { - let id = doc.attribute(node, "id").map(String::from); - let is_abstract = doc.attribute(node, "abstract") == Some("true"); - let is_a = doc.attribute(node, "is-a").map(String::from); - let mut variables = Vec::new(); - let mut rules = Vec::new(); - let mut params = Vec::new(); - - for child in doc.children(node) { - if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { - continue; - } - let name = doc.node_name(child).unwrap_or(""); - let child_ns = doc.node_namespace(child).unwrap_or(""); - - if !is_sch_name_in_mode(ns_mode, child_ns) { - continue; - } - - let local = sch_local_name(name); - match local { - "let" => { - if let Some(binding) = parse_let_binding(doc, child) { - variables.push(binding); - } - } - "rule" => { - rules.push(parse_rule(doc, ns_mode, child)?); - } - "param" => { - if let (Some(pname), Some(pvalue)) = - (doc.attribute(child, "name"), doc.attribute(child, "value")) - { - params.push((pname.to_owned(), pvalue.to_owned())); - } - } - _ => {} - } - } - - Ok(SchematronPattern { - id, - is_abstract, - is_a, - params, - variables, - rules, - }) -} - -/// Parses a `<sch:rule>` element. -fn parse_rule( - doc: &Document, - ns_mode: &NsMode, - node: NodeId, -) -> Result<SchematronRule, ValidationError> { - let context = doc - .attribute(node, "context") - .ok_or_else(|| ValidationError { - message: "rule element is missing required 'context' attribute".to_string(), - line: None, - column: None, - })? - .to_owned(); - - let mut variables = Vec::new(); - let mut checks = Vec::new(); - - for child in doc.children(node) { - if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { - continue; - } - let name = doc.node_name(child).unwrap_or(""); - let child_ns = doc.node_namespace(child).unwrap_or(""); - - if !is_sch_name_in_mode(ns_mode, child_ns) { - continue; - } - - let local = sch_local_name(name); - match local { - "let" => { - if let Some(binding) = parse_let_binding(doc, child) { - variables.push(binding); - } - } - "assert" => { - if let Some(check) = parse_check(doc, ns_mode, child, true) { - checks.push(check); - } - } - "report" => { - if let Some(check) = parse_check(doc, ns_mode, child, false) { - checks.push(check); - } - } - _ => {} - } - } - - Ok(SchematronRule { - context, - variables, - checks, - }) -} - -/// Parses a `<sch:assert>` or `<sch:report>` element. -fn parse_check( - doc: &Document, - ns_mode: &NsMode, - node: NodeId, - is_assert: bool, -) -> Option<SchematronCheck> { - let test = doc.attribute(node, "test")?.to_owned(); - let message = parse_message_parts(doc, ns_mode, node); - if is_assert { - Some(SchematronCheck::Assert { test, message }) - } else { - Some(SchematronCheck::Report { test, message }) - } -} - -/// Parses the mixed content of an assert/report element into message parts. -fn parse_message_parts(doc: &Document, ns_mode: &NsMode, node: NodeId) -> Vec<MessagePart> { - let mut parts = Vec::new(); - for child in doc.children(node) { - match &doc.node(child).kind { - NodeKind::Text { content } if !content.is_empty() => { - parts.push(MessagePart::Text(content.clone())); - } - NodeKind::Element { .. } => { - let name = doc.node_name(child).unwrap_or(""); - let child_ns = doc.node_namespace(child).unwrap_or(""); - if is_sch_name_in_mode(ns_mode, child_ns) && sch_local_name(name) == "value-of" { - if let Some(select) = doc.attribute(child, "select") { - parts.push(MessagePart::ValueOf { - select: select.to_owned(), - }); - } - } - } - _ => {} - } - } - parts -} - -/// Parses a `<sch:phase>` element. -fn parse_phase(doc: &Document, ns_mode: &NsMode, node: NodeId) -> Option<Phase> { - let id = doc.attribute(node, "id")?.to_owned(); - let mut active_patterns = Vec::new(); - - for child in doc.children(node) { - if !matches!(doc.node(child).kind, NodeKind::Element { .. }) { - continue; - } - let name = doc.node_name(child).unwrap_or(""); - let child_ns = doc.node_namespace(child).unwrap_or(""); - if is_sch_name_in_mode(ns_mode, child_ns) && sch_local_name(name) == "active" { - if let Some(pattern) = doc.attribute(child, "pattern") { - active_patterns.push(pattern.to_owned()); - } - } - } - - Some(Phase { - id, - active_patterns, - }) -} - -// --------------------------------------------------------------------------- -// Validator -// --------------------------------------------------------------------------- - -/// Validates a document against a Schematron schema. -/// -/// Evaluates all patterns (or the default phase's patterns) and returns -/// a [`ValidationResult`] with errors from failed assertions and warnings -/// from fired reports. -/// -/// # Examples -/// -/// ``` -/// use xmloxide::Document; -/// use xmloxide::validation::schematron::{parse_schematron, validate_schematron}; -/// -/// let schema = parse_schematron(r#" -/// <schema xmlns="http://purl.oclc.org/dml/schematron"> -/// <pattern> -/// <rule context="/root"> -/// <assert test="child">root must have a child element</assert> -/// </rule> -/// </pattern> -/// </schema> -/// "#).unwrap(); -/// -/// let doc = Document::parse_str("<root><child/></root>").unwrap(); -/// let result = validate_schematron(&doc, &schema); -/// assert!(result.is_valid); -/// ``` -pub fn validate_schematron(doc: &Document, schema: &SchematronSchema) -> ValidationResult { - if let Some(ref phase_id) = schema.default_phase { - validate_schematron_with_phase(doc, schema, phase_id) - } else { - validate_patterns(doc, schema, &schema.patterns) - } -} - -/// Validates a document against a Schematron schema using a specific phase. -/// -/// Only patterns referenced by `<sch:active>` elements within the named -/// phase are evaluated. -/// -/// # Examples -/// -/// ``` -/// use xmloxide::Document; -/// use xmloxide::validation::schematron::{parse_schematron, validate_schematron_with_phase}; -/// -/// let schema = parse_schematron(r#" -/// <schema xmlns="http://purl.oclc.org/dml/schematron"> -/// <phase id="quick"> -/// <active pattern="basic"/> -/// </phase> -/// <pattern id="basic"> -/// <rule context="/*"> -/// <assert test="true()">always passes</assert> -/// </rule> -/// </pattern> -/// <pattern id="strict"> -/// <rule context="/*"> -/// <assert test="false()">always fails</assert> -/// </rule> -/// </pattern> -/// </schema> -/// "#).unwrap(); -/// -/// let doc = Document::parse_str("<root/>").unwrap(); -/// let result = validate_schematron_with_phase(&doc, &schema, "quick"); -/// assert!(result.is_valid); -/// ``` -pub fn validate_schematron_with_phase( - doc: &Document, - schema: &SchematronSchema, - phase_id: &str, -) -> ValidationResult { - if let Some(phase) = schema.phases.get(phase_id) { - let active_ids: HashSet<&str> = phase.active_patterns.iter().map(String::as_str).collect(); - let active_patterns: Vec<&SchematronPattern> = schema - .patterns - .iter() - .filter(|p| { - p.id.as_ref() - .is_some_and(|id| active_ids.contains(id.as_str())) - }) - .collect(); - validate_pattern_refs(doc, schema, &active_patterns) - } else { - // Unknown phase — validate all patterns - validate_patterns(doc, schema, &schema.patterns) - } -} - -/// Validates a set of patterns (owned references). -fn validate_patterns( - doc: &Document, - schema: &SchematronSchema, - patterns: &[SchematronPattern], -) -> ValidationResult { - let refs: Vec<&SchematronPattern> = patterns.iter().collect(); - validate_pattern_refs(doc, schema, &refs) -} - -/// Core validation logic operating on a slice of pattern references. -fn validate_pattern_refs( - doc: &Document, - schema: &SchematronSchema, - patterns: &[&SchematronPattern], -) -> ValidationResult { - let mut errors = Vec::new(); - let mut warnings = Vec::new(); - - let root = doc.root(); - let ns = &schema.namespaces; - - // Evaluate schema-level variables at the document root - let root_node = XPathNode::Node(root); - let schema_vars = eval_variables(doc, root_node, &schema.variables, &HashMap::new(), ns); - - for pattern in patterns { - // Per-pattern fired_nodes tracking (firing rule semantics) - let mut fired_nodes: HashSet<XPathNode> = HashSet::new(); - - // Evaluate pattern-level variables - let mut pattern_vars = schema_vars.clone(); - let extra = eval_variables(doc, root_node, &pattern.variables, &pattern_vars, ns); - pattern_vars.extend(extra); - - for rule in &pattern.rules { - // Evaluate the context XPath to find matching nodes - let context_nodes = - match eval_context_xpath(doc, root, &rule.context, &pattern_vars, ns) { - Ok(nodes) => nodes, - Err(e) => { - errors.push(ValidationError { - message: format!( - "XPath error in rule context '{}': {}", - rule.context, e - ), - line: None, - column: None, - }); - continue; - } - }; - - for &node in &context_nodes { - // Firing rule: skip nodes already fired in this pattern - if fired_nodes.contains(&node) { - continue; - } - fired_nodes.insert(node); - - // Evaluate rule-level variables at this context node - let mut rule_vars = pattern_vars.clone(); - let extra = eval_variables(doc, node, &rule.variables, &rule_vars, ns); - rule_vars.extend(extra); - - for check in &rule.checks { - match check { - SchematronCheck::Assert { test, message } => { - match eval_test(doc, node, test, &rule_vars, ns) { - Ok(true) => {} // assertion satisfied - Ok(false) => { - let msg = - interpolate_message(doc, node, message, &rule_vars, ns); - errors.push(ValidationError { - message: msg, - line: None, - column: None, - }); - } - Err(e) => { - errors.push(ValidationError { - message: format!( - "XPath error in assert test '{test}': {e}" - ), - line: None, - column: None, - }); - } - } - } - SchematronCheck::Report { test, message } => { - match eval_test(doc, node, test, &rule_vars, ns) { - Ok(true) => { - let msg = - interpolate_message(doc, node, message, &rule_vars, ns); - warnings.push(ValidationError { - message: msg, - line: None, - column: None, - }); - } - Ok(false) => {} // report condition not met - Err(e) => { - errors.push(ValidationError { - message: format!( - "XPath error in report test '{test}': {e}" - ), - line: None, - column: None, - }); - } - } - } - } - } - } - } - } - - ValidationResult { - is_valid: errors.is_empty(), - errors, - warnings, - } -} - -/// Creates an `XPathContext` with variables and namespace bindings. -/// -/// The context node may be an attribute node (e.g., for rules whose -/// context expression selects attributes). -fn make_xpath_context<'a>( - doc: &'a Document, - node: XPathNode, - variables: &HashMap<String, XPathValue>, - ns_bindings: &[NamespaceBinding], -) -> XPathContext<'a> { - let mut ctx = XPathContext::new_at(doc, node); - for (name, value) in variables { - ctx.set_variable(name, value.clone()); - } - for ns in ns_bindings { - ctx.set_namespace(&ns.prefix, &ns.uri); - } - ctx -} - -/// Evaluates an `XPath` context expression and returns the matching nodes. -/// -/// The result may contain attribute nodes: a rule whose context selects -/// attributes fires with the attribute itself as the context node. -fn eval_context_xpath( - doc: &Document, - root: NodeId, - xpath_expr: &str, - variables: &HashMap<String, XPathValue>, - ns_bindings: &[NamespaceBinding], -) -> Result<Vec<XPathNode>, XPathError> { - let expr = xpath::parser::parse(xpath_expr)?; - let ctx = make_xpath_context(doc, XPathNode::Node(root), variables, ns_bindings); - let result = ctx.evaluate(&expr)?; - match result { - XPathValue::NodeSet(nodes) => Ok(nodes), - _ => Ok(vec![]), - } -} - -/// Evaluates a test expression at a context node, returning a boolean. -fn eval_test( - doc: &Document, - node: XPathNode, - test_expr: &str, - variables: &HashMap<String, XPathValue>, - ns_bindings: &[NamespaceBinding], -) -> Result<bool, XPathError> { - let expr = xpath::parser::parse(test_expr)?; - let ctx = make_xpath_context(doc, node, variables, ns_bindings); - let result = ctx.evaluate(&expr)?; - Ok(result.to_boolean()) -} - -/// Evaluates `<sch:let>` bindings and returns the resulting variable map. -fn eval_variables( - doc: &Document, - context_node: XPathNode, - bindings: &[LetBinding], - existing: &HashMap<String, XPathValue>, - ns_bindings: &[NamespaceBinding], -) -> HashMap<String, XPathValue> { - let mut result = HashMap::new(); - // Accumulate so later bindings can reference earlier ones - let mut combined = existing.clone(); - - for binding in bindings { - // Try to evaluate as XPath; fall back to string literal - let value = if let Ok(expr) = xpath::parser::parse(&binding.value) { - let ctx = make_xpath_context(doc, context_node, &combined, ns_bindings); - ctx.evaluate(&expr) - .unwrap_or_else(|_| XPathValue::String(binding.value.clone())) - } else { - XPathValue::String(binding.value.clone()) - }; - - result.insert(binding.name.clone(), value.clone()); - combined.insert(binding.name.clone(), value); - } - - result -} - -/// Interpolates message parts by evaluating `<sch:value-of>` expressions. -fn interpolate_message( - doc: &Document, - node: XPathNode, - parts: &[MessagePart], - variables: &HashMap<String, XPathValue>, - ns_bindings: &[NamespaceBinding], -) -> String { - let mut result = String::new(); - for part in parts { - match part { - MessagePart::Text(text) => result.push_str(text), - MessagePart::ValueOf { select } => { - if let Ok(expr) = xpath::parser::parse(select) { - let ctx = make_xpath_context(doc, node, variables, ns_bindings); - if let Ok(val) = ctx.evaluate(&expr) { - result.push_str(&xpath_value_to_string(doc, &val)); - } - } - } - } - } - result -} - -/// Converts an `XPath` value to a string, computing string-value for -/// node-sets using the document (unlike `to_xpath_string()` which returns -/// empty for node-sets without document access). -fn xpath_value_to_string(doc: &Document, val: &XPathValue) -> String { - match val { - XPathValue::NodeSet(nodes) => match nodes.first() { - Some(&XPathNode::Attribute { owner, index }) => doc - .attributes(owner) - .get(index as usize) - .map(|a| a.value.clone()) - .unwrap_or_default(), - Some(&XPathNode::Node(id)) => doc.text_content(id), - None => String::new(), - }, - _ => val.to_xpath_string(), - } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - - // =================================================================== - // Phase 1: Parsing tests - // =================================================================== - - #[test] - fn test_parse_minimal_schema() { - let schema = - parse_schematron(r#"<schema xmlns="http://purl.oclc.org/dml/schematron"/>"#).unwrap(); - assert!(schema.patterns.is_empty()); - assert!(schema.namespaces.is_empty()); - assert!(schema.variables.is_empty()); - assert!(schema.phases.is_empty()); - assert!(schema.default_phase.is_none()); - } - - #[test] - fn test_parse_single_assert() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="/root"> - <assert test="@id">root must have an id</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - assert_eq!(schema.patterns.len(), 1); - assert_eq!(schema.patterns[0].rules.len(), 1); - assert_eq!(schema.patterns[0].rules[0].context, "/root"); - assert_eq!(schema.patterns[0].rules[0].checks.len(), 1); - match &schema.patterns[0].rules[0].checks[0] { - SchematronCheck::Assert { test, message } => { - assert_eq!(test, "@id"); - assert_eq!(message.len(), 1); - match &message[0] { - MessagePart::Text(t) => assert_eq!(t, "root must have an id"), - MessagePart::ValueOf { .. } => panic!("expected Text message part"), - } - } - SchematronCheck::Report { .. } => panic!("expected Assert check"), - } - } - - #[test] - fn test_parse_report() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="//item"> - <report test="@deprecated">item is deprecated</report> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - match &schema.patterns[0].rules[0].checks[0] { - SchematronCheck::Report { test, message } => { - assert_eq!(test, "@deprecated"); - assert_eq!(message.len(), 1); - } - SchematronCheck::Assert { .. } => panic!("expected Report check"), - } - } - - #[test] - fn test_parse_multiple_patterns() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern id="p1"> - <rule context="/a"> - <assert test="b">need b</assert> - </rule> - </pattern> - <pattern id="p2"> - <rule context="/a"> - <assert test="c">need c</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - assert_eq!(schema.patterns.len(), 2); - assert_eq!(schema.patterns[0].id.as_deref(), Some("p1")); - assert_eq!(schema.patterns[1].id.as_deref(), Some("p2")); - } - - #[test] - fn test_parse_ns_bindings() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <ns prefix="inv" uri="urn:invoice"/> - <ns prefix="cbc" uri="urn:oasis:names:cbc"/> - </schema> - "#, - ) - .unwrap(); - assert_eq!(schema.namespaces.len(), 2); - assert_eq!(schema.namespaces[0].prefix, "inv"); - assert_eq!(schema.namespaces[0].uri, "urn:invoice"); - assert_eq!(schema.namespaces[1].prefix, "cbc"); - assert_eq!(schema.namespaces[1].uri, "urn:oasis:names:cbc"); - } - - #[test] - fn test_parse_let_bindings() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <let name="threshold" value="100"/> - <pattern> - <let name="pat_var" value="'hello'"/> - <rule context="/*"> - <let name="rule_var" value="@count"/> - <assert test="$rule_var > $threshold">too low</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - assert_eq!(schema.variables.len(), 1); - assert_eq!(schema.variables[0].name, "threshold"); - assert_eq!(schema.variables[0].value, "100"); - assert_eq!(schema.patterns[0].variables.len(), 1); - assert_eq!(schema.patterns[0].variables[0].name, "pat_var"); - assert_eq!(schema.patterns[0].rules[0].variables.len(), 1); - assert_eq!(schema.patterns[0].rules[0].variables[0].name, "rule_var"); - } - - #[test] - fn test_parse_value_of_in_message() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="/root"> - <assert test="@id">element <value-of select="name()"/> must have an id</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - let check = &schema.patterns[0].rules[0].checks[0]; - match check { - SchematronCheck::Assert { message, .. } => { - assert_eq!(message.len(), 3); - match &message[0] { - MessagePart::Text(t) => assert_eq!(t, "element "), - MessagePart::ValueOf { .. } => panic!("expected Text"), - } - match &message[1] { - MessagePart::ValueOf { select } => assert_eq!(select, "name()"), - MessagePart::Text(_) => panic!("expected ValueOf"), - } - match &message[2] { - MessagePart::Text(t) => assert_eq!(t, " must have an id"), - MessagePart::ValueOf { .. } => panic!("expected Text"), - } - } - SchematronCheck::Report { .. } => panic!("expected Assert"), - } - } - - #[test] - fn test_parse_error_missing_context() { - let result = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule> - <assert test="true()">ok</assert> - </rule> - </pattern> - </schema> - "#, - ); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - err.message.contains("context"), - "error should mention 'context': {}", - err.message - ); - } - - // =================================================================== - // Phase 2: Basic validation tests - // =================================================================== - - #[test] - fn test_validate_assert_passes() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="/root"> - <assert test="child">root must have a child</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - let doc = Document::parse_str("<root><child/></root>").unwrap(); - let result = validate_schematron(&doc, &schema); - assert!(result.is_valid); - assert!(result.errors.is_empty()); - } - - #[test] - fn test_validate_assert_fails() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="/root"> - <assert test="child">root must have a child</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - let doc = Document::parse_str("<root/>").unwrap(); - let result = validate_schematron(&doc, &schema); - assert!(!result.is_valid); - assert_eq!(result.errors.len(), 1); - assert_eq!(result.errors[0].message, "root must have a child"); - } - - #[test] - fn test_validate_report_fires() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="/root"> - <report test="@deprecated">element is deprecated</report> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - let doc = Document::parse_str(r#"<root deprecated="true"/>"#).unwrap(); - let result = validate_schematron(&doc, &schema); - // Reports produce warnings, not errors - assert!(result.is_valid); - assert_eq!(result.warnings.len(), 1); - assert_eq!(result.warnings[0].message, "element is deprecated"); - } - - #[test] - fn test_validate_report_silent() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="/root"> - <report test="@deprecated">element is deprecated</report> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - let doc = Document::parse_str("<root/>").unwrap(); - let result = validate_schematron(&doc, &schema); - assert!(result.is_valid); - assert!(result.warnings.is_empty()); - } - - #[test] - fn test_validate_multiple_asserts() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="/root"> - <assert test="@id">must have id</assert> - <assert test="child">must have child</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - let doc = Document::parse_str("<root/>").unwrap(); - let result = validate_schematron(&doc, &schema); - assert!(!result.is_valid); - assert_eq!(result.errors.len(), 2); - } - - #[test] - fn test_validate_context_multiple_nodes() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="//item"> - <assert test="@name">item must have name</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - let doc = - Document::parse_str(r#"<root><item name="a"/><item/><item name="c"/></root>"#).unwrap(); - let result = validate_schematron(&doc, &schema); - assert!(!result.is_valid); - // Only the second <item> lacks @name - assert_eq!(result.errors.len(), 1); - } - - #[test] - fn test_validate_no_matching_nodes() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="//nonexistent"> - <assert test="false()">should never fire</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - let doc = Document::parse_str("<root/>").unwrap(); - let result = validate_schematron(&doc, &schema); - assert!(result.is_valid); - } - - #[test] - fn test_validate_multiple_patterns() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="/root"> - <assert test="@id">need id</assert> - </rule> - </pattern> - <pattern> - <rule context="/root"> - <assert test="child">need child</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - let doc = Document::parse_str("<root/>").unwrap(); - let result = validate_schematron(&doc, &schema); - assert!(!result.is_valid); - // Both patterns fire on the same node (different patterns = independent) - assert_eq!(result.errors.len(), 2); - } - - // =================================================================== - // Phase 3: Firing rules tests - // =================================================================== - - #[test] - fn test_firing_rule_first_wins() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="/root"> - <assert test="true()">first rule passes</assert> - </rule> - <rule context="/root"> - <assert test="false()">second rule would fail</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - let doc = Document::parse_str("<root/>").unwrap(); - let result = validate_schematron(&doc, &schema); - // The second rule never fires because /root already fired in rule 1 - assert!(result.is_valid); - } - - #[test] - fn test_firing_rule_across_patterns() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="/root"> - <assert test="true()">pattern 1 passes</assert> - </rule> - </pattern> - <pattern> - <rule context="/root"> - <assert test="false()">pattern 2 fails</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - let doc = Document::parse_str("<root/>").unwrap(); - let result = validate_schematron(&doc, &schema); - // Same node fires independently in each pattern - assert!(!result.is_valid); - assert_eq!(result.errors.len(), 1); - } - - // =================================================================== - // Phase 4: Variables tests - // =================================================================== - - #[test] - fn test_variable_schema_level() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <let name="threshold" value="100"/> - <pattern> - <rule context="/root"> - <assert test="@count >= $threshold">count must be at least 100</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - - let doc_pass = Document::parse_str(r#"<root count="150"/>"#).unwrap(); - assert!(validate_schematron(&doc_pass, &schema).is_valid); - - let doc_fail = Document::parse_str(r#"<root count="50"/>"#).unwrap(); - assert!(!validate_schematron(&doc_fail, &schema).is_valid); - } - - #[test] - fn test_variable_rule_level() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="/root"> - <let name="n" value="@name"/> - <assert test="string-length($n) > 0">name must not be empty</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - - let doc_pass = Document::parse_str(r#"<root name="hello"/>"#).unwrap(); - assert!(validate_schematron(&doc_pass, &schema).is_valid); - - let doc_fail = Document::parse_str(r#"<root name=""/>"#).unwrap(); - assert!(!validate_schematron(&doc_fail, &schema).is_valid); - } - - #[test] - fn test_variable_xpath_expression() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="/root"> - <let name="total" value="count(item)"/> - <assert test="$total > 0">must have at least one item</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - - let doc_pass = Document::parse_str("<root><item/><item/></root>").unwrap(); - assert!(validate_schematron(&doc_pass, &schema).is_valid); - - let doc_fail = Document::parse_str("<root/>").unwrap(); - assert!(!validate_schematron(&doc_fail, &schema).is_valid); - } - - // =================================================================== - // Phase 5: Message interpolation tests - // =================================================================== - - #[test] - fn test_message_value_of() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="/root"> - <assert test="false()">element <value-of select="name()"/> failed</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - let doc = Document::parse_str("<root/>").unwrap(); - let result = validate_schematron(&doc, &schema); - assert_eq!(result.errors.len(), 1); - assert_eq!(result.errors[0].message, "element root failed"); - } - - #[test] - fn test_message_mixed() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="/order"> - <assert test="false()">Order <value-of select="@id"/> has <value-of select="count(item)"/> items</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - let doc = Document::parse_str(r#"<order id="42"><item/><item/><item/></order>"#).unwrap(); - let result = validate_schematron(&doc, &schema); - assert_eq!(result.errors.len(), 1); - assert_eq!(result.errors[0].message, "Order 42 has 3 items"); - } - - #[test] - fn test_attribute_rule_context() { - // A rule context selecting attribute nodes evaluates its asserts - // with the ATTRIBUTE as the context node: `.` is the attribute - // value, not the owner element's text content. - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="//@id"> - <assert test=". = 'x'">id must be x (got <value-of select="."/>)</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - // Valid: the id attribute IS 'x' even though the element text differs. - let doc = Document::parse_str(r#"<r><a id="x">element text</a></r>"#).unwrap(); - let result = validate_schematron(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - - // Invalid: the assert fails and value-of reads the attribute value. - let doc2 = Document::parse_str(r#"<r><a id="y">element text</a></r>"#).unwrap(); - let result2 = validate_schematron(&doc2, &schema); - assert!(!result2.is_valid); - assert_eq!(result2.errors[0].message, "id must be x (got y)"); - } - - #[test] - fn test_attribute_rule_context_does_not_mask_element_rules() { - // Firing an attribute-context rule must not mark the owner ELEMENT - // as fired for later rules in the same pattern. - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="//item/@code"> - <assert test="string-length(.) >= 3">code too short</assert> - </rule> - <rule context="//item"> - <assert test="@name">item must have a name attribute</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - // code is fine but @name is missing: exactly the element rule fires. - let doc = Document::parse_str(r#"<order><item code="XYZ"/></order>"#).unwrap(); - let result = validate_schematron(&doc, &schema); - assert_eq!(result.errors.len(), 1, "errors: {:?}", result.errors); - assert_eq!(result.errors[0].message, "item must have a name attribute"); - } - - // =================================================================== - // Phase 6: Phases + integration tests - // =================================================================== - - #[test] - fn test_phase_selective() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron" defaultPhase="quick"> - <phase id="quick"> - <active pattern="basic"/> - </phase> - <pattern id="basic"> - <rule context="/*"> - <assert test="true()">basic passes</assert> - </rule> - </pattern> - <pattern id="strict"> - <rule context="/*"> - <assert test="false()">strict fails</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - let doc = Document::parse_str("<root/>").unwrap(); - - // Default phase is "quick", which only activates "basic" - let result = validate_schematron(&doc, &schema); - assert!(result.is_valid); - - let schema2 = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <phase id="quick"> - <active pattern="basic"/> - </phase> - <phase id="full"> - <active pattern="basic"/> - <active pattern="strict"/> - </phase> - <pattern id="basic"> - <rule context="/*"> - <assert test="true()">basic passes</assert> - </rule> - </pattern> - <pattern id="strict"> - <rule context="/*"> - <assert test="false()">strict fails</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - - let quick = validate_schematron_with_phase(&doc, &schema2, "quick"); - assert!(quick.is_valid); - - let full = validate_schematron_with_phase(&doc, &schema2, "full"); - assert!(!full.is_valid); - assert_eq!(full.errors.len(), 1); - } - - #[test] - fn test_validate_invoice_schema() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <let name="min_items" value="1"/> - <pattern id="structure"> - <rule context="/invoice"> - <assert test="@id">Invoice must have an id</assert> - <assert test="customer">Invoice must have a customer</assert> - <assert test="count(item) >= $min_items">Invoice must have at least <value-of select="$min_items"/> item(s)</assert> - </rule> - </pattern> - <pattern id="amounts"> - <rule context="//item"> - <assert test="number(@amount) > 0">Item <value-of select="@name"/> amount must be positive</assert> - </rule> - </pattern> - <pattern id="names"> - <rule context="//item"> - <assert test="@name">Every item must have a name</assert> - <report test="@discount">Item <value-of select="@name"/> has a discount applied</report> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - - // Valid invoice - let valid_doc = Document::parse_str( - r#"<invoice id="INV-001"> - <customer>Acme Corp</customer> - <item name="Widget" amount="10"/> - <item name="Gadget" amount="20"/> - </invoice>"#, - ) - .unwrap(); - let result = validate_schematron(&valid_doc, &schema); - assert!( - result.is_valid, - "valid invoice should pass: {:?}", - result.errors - ); - - // Invalid: missing id, no customer, zero amount - let invalid_doc = Document::parse_str( - r#"<invoice> - <item name="Widget" amount="10"/> - <item name="Gadget" amount="0"/> - </invoice>"#, - ) - .unwrap(); - let result = validate_schematron(&invalid_doc, &schema); - assert!(!result.is_valid); - // Errors: missing id, missing customer, zero amount on Gadget - assert!( - result.errors.len() >= 3, - "expected at least 3 errors, got {}: {:?}", - result.errors.len(), - result.errors - ); - - // Test report fires on discount attribute - let discount_doc = Document::parse_str( - r#"<invoice id="INV-002"> - <customer>Beta Corp</customer> - <item name="Widget" amount="10" discount="5"/> - </invoice>"#, - ) - .unwrap(); - let result = validate_schematron(&discount_doc, &schema); - assert!(result.is_valid); - assert_eq!(result.warnings.len(), 1); - assert_eq!( - result.warnings[0].message, - "Item Widget has a discount applied" - ); - } - - #[test] - fn test_xpath_error_recovery() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="/root"> - <assert test="[[[invalid xpath">should not crash</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - let doc = Document::parse_str("<root/>").unwrap(); - let result = validate_schematron(&doc, &schema); - // Should report an error about XPath, not panic - assert!(!result.is_valid); - assert!( - result.errors[0].message.contains("XPath error"), - "error should mention XPath: {}", - result.errors[0].message - ); - } - - #[test] - fn test_validate_sum_attribute_path() { - // Tests that sum(child/@attr) works correctly now that - // attribute paths return proper NodeSets. - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="/order"> - <let name="total" value="sum(item/@price)"/> - <assert test="$total = @expected">Total <value-of select="$total"/> does not match expected <value-of select="@expected"/></assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - - let doc_pass = Document::parse_str( - r#"<order expected="30"><item price="10"/><item price="20"/></order>"#, - ) - .unwrap(); - let result = validate_schematron(&doc_pass, &schema); - assert!(result.is_valid, "sum should equal 30: {:?}", result.errors); - - let doc_fail = Document::parse_str( - r#"<order expected="99"><item price="10"/><item price="20"/></order>"#, - ) - .unwrap(); - let result = validate_schematron(&doc_fail, &schema); - assert!(!result.is_valid); - } - - // =================================================================== - // Namespace-prefixed XPath tests - // =================================================================== - - #[test] - fn test_namespace_prefixed_xpath() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <ns prefix="inv" uri="urn:example:invoice"/> - <pattern> - <rule context="/inv:invoice"> - <assert test="inv:customer">Invoice must have a customer</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - - // Document with namespace - let doc_pass = Document::parse_str( - r#"<invoice xmlns="urn:example:invoice"><customer>Acme</customer></invoice>"#, - ) - .unwrap(); - let result = validate_schematron(&doc_pass, &schema); - assert!( - result.is_valid, - "namespace-prefixed XPath should match: {:?}", - result.errors - ); - - // Document with namespace but missing customer - let doc_fail = Document::parse_str(r#"<invoice xmlns="urn:example:invoice"/>"#).unwrap(); - let result = validate_schematron(&doc_fail, &schema); - assert!(!result.is_valid); - assert_eq!(result.errors.len(), 1); - } - - #[test] - fn test_namespace_prefix_wildcard() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <ns prefix="inv" uri="urn:example:invoice"/> - <pattern> - <rule context="/inv:*"> - <assert test="@id">Root element must have an id</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - - let doc = Document::parse_str(r#"<invoice xmlns="urn:example:invoice" id="1"/>"#).unwrap(); - let result = validate_schematron(&doc, &schema); - assert!(result.is_valid); - - let doc_fail = Document::parse_str(r#"<invoice xmlns="urn:example:invoice"/>"#).unwrap(); - let result = validate_schematron(&doc_fail, &schema); - assert!(!result.is_valid); - } - - // =================================================================== - // Additional edge case tests - // =================================================================== - - #[test] - fn test_matches_function() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern> - <rule context="/order"> - <assert test="matches(@country, '[A-Z]{2}')">Country must be a 2-letter ISO code</assert> - <assert test="matches(@id, '[A-Z]+-\d+')">ID must match format LETTERS-DIGITS</assert> - </rule> - </pattern> - </schema> - "#, - ) - .unwrap(); - - let doc_pass = Document::parse_str(r#"<order country="US" id="INV-42"/>"#).unwrap(); - assert!(validate_schematron(&doc_pass, &schema).is_valid); - - let doc_fail = Document::parse_str(r#"<order country="usa" id="123"/>"#).unwrap(); - let result = validate_schematron(&doc_fail, &schema); - assert!(!result.is_valid); - assert_eq!(result.errors.len(), 2); - } - - #[test] - fn test_classic_namespace() { - let schema = parse_schematron( - r#"<schema xmlns="http://www.ascc.net/xml/schematron"> - <pattern> - <rule context="/*"> - <assert test="true()">ok</assert> - </rule> - </pattern> - </schema>"#, - ) - .unwrap(); - assert_eq!(schema.patterns.len(), 1); - let doc = Document::parse_str("<root/>").unwrap(); - assert!(validate_schematron(&doc, &schema).is_valid); - } - - #[test] - fn test_prefixed_schema() { - let schema = parse_schematron( - r#"<sch:schema xmlns:sch="http://purl.oclc.org/dml/schematron"> - <sch:pattern> - <sch:rule context="/*"> - <sch:assert test="true()">ok</sch:assert> - </sch:rule> - </sch:pattern> - </sch:schema>"#, - ) - .unwrap(); - assert_eq!(schema.patterns.len(), 1); - } - - // =================================================================== - // Abstract pattern tests - // =================================================================== - - #[test] - fn test_abstract_pattern_basic() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern id="req_attr" abstract="true"> - <rule context="$element"> - <assert test="@$attr">Element must have $attr attribute</assert> - </rule> - </pattern> - <pattern id="check_id" is-a="req_attr"> - <param name="element" value="//item"/> - <param name="attr" value="id"/> - </pattern> - <pattern id="check_name" is-a="req_attr"> - <param name="element" value="//item"/> - <param name="attr" value="name"/> - </pattern> - </schema> - "#, - ) - .unwrap(); - - // Abstract pattern should be excluded, two concrete patterns remain - assert_eq!(schema.patterns.len(), 2); - - // First pattern should have context "//item" and test "@id" - assert_eq!(schema.patterns[0].rules[0].context, "//item"); - match &schema.patterns[0].rules[0].checks[0] { - SchematronCheck::Assert { test, .. } => assert_eq!(test, "@id"), - SchematronCheck::Report { .. } => panic!("expected assert"), - } - - // Validate - let doc_pass = Document::parse_str(r#"<root><item id="1" name="x"/></root>"#).unwrap(); - assert!(validate_schematron(&doc_pass, &schema).is_valid); - - let doc_fail = Document::parse_str(r#"<root><item id="1"/></root>"#).unwrap(); - let result = validate_schematron(&doc_fail, &schema); - assert!(!result.is_valid); - // Missing name attribute - assert_eq!(result.errors.len(), 1); - } - - #[test] - fn test_abstract_pattern_multiple_rules() { - let schema = parse_schematron( - r#" - <schema xmlns="http://purl.oclc.org/dml/schematron"> - <pattern id="has_content" abstract="true"> - <rule context="$ctx"> - <assert test="string-length(normalize-space(.)) > 0">$ctx must not be empty</assert> - </rule> - </pattern> - <pattern is-a="has_content"> - <param name="ctx" value="/doc/title"/> - </pattern> - <pattern is-a="has_content"> - <param name="ctx" value="/doc/body"/> - </pattern> - </schema> - "#, - ) - .unwrap(); - - let doc_pass = - Document::parse_str("<doc><title>Hi</title><body>Content</body></doc>").unwrap(); - assert!(validate_schematron(&doc_pass, &schema).is_valid); - - let doc_fail = Document::parse_str("<doc><title>Hi</title><body> </body></doc>").unwrap(); - assert!(!validate_schematron(&doc_fail, &schema).is_valid); - } -} diff --git a/browser/vendor/xmloxide/src/validation/xsd.rs b/browser/vendor/xmloxide/src/validation/xsd.rs deleted file mode 100644 index fb0bc0d1b..000000000 --- a/browser/vendor/xmloxide/src/validation/xsd.rs +++ /dev/null @@ -1,3731 +0,0 @@ -//! XML Schema (XSD 1.0) validation for XML documents. -//! -//! This module implements a subset of the W3C XML Schema Definition Language -//! (XSD) 1.0 specification (<https://www.w3.org/TR/xmlschema-1/>) for -//! validating XML documents against XSD schemas. -//! -//! # Supported Features -//! -//! - Global and local element declarations with type references or inline types -//! - Complex types with `sequence`, `choice`, `all`, and empty content models -//! - Simple types with restriction facets, list, and union varieties -//! - Built-in XSD datatypes (string, integer, boolean, date, etc.) -//! - Attribute declarations with required/optional, default, and fixed values -//! - Occurrence constraints (`minOccurs`, `maxOccurs`) -//! - Mixed content -//! - Attribute groups -//! - Simple content extensions -//! -//! # Architecture -//! -//! 1. **Data model** ([`XsdSchema`], [`XsdElement`], [`XsdType`], etc.) -- an -//! algebraic representation of the schema structure. -//! 2. **Schema parser** ([`parse_xsd`]) -- reads an XSD XML document and -//! produces an `XsdSchema`. -//! 3. **Validator** ([`validate_xsd`]) -- checks an XML document tree against -//! a compiled schema. -//! -//! # Examples -//! -//! ``` -//! use xmloxide::Document; -//! use xmloxide::validation::xsd::{parse_xsd, validate_xsd}; -//! -//! let schema_xml = r#" -//! <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> -//! <xs:element name="greeting" type="xs:string"/> -//! </xs:schema> -//! "#; -//! -//! let schema = parse_xsd(schema_xml).unwrap(); -//! let doc = Document::parse_str("<greeting>Hello!</greeting>").unwrap(); -//! let result = validate_xsd(&doc, &schema); -//! assert!(result.is_valid); -//! ``` - -use std::collections::{HashMap, HashSet}; - -use crate::tree::{Document, NodeId, NodeKind}; -use crate::validation::{ValidationError, ValidationResult}; - -/// The XML Schema namespace URI. -const XSD_NAMESPACE: &str = "http://www.w3.org/2001/XMLSchema"; - -// --------------------------------------------------------------------------- -// Schema resolver -// --------------------------------------------------------------------------- - -/// A trait for resolving external schema documents by URI. -/// -/// Implementors provide schema content for `xsd:import` and `xsd:include` -/// directives. The resolver receives the `schemaLocation` URI and an optional -/// base URI for resolving relative paths. -/// -/// A blanket implementation is provided for closures matching -/// `Fn(&str, Option<&str>) -> Option<String>`. -/// -/// See XSD 1.0 section 4.2 for schema composition. -pub trait SchemaResolver { - /// Resolves a schema location to its XML content. - /// - /// `location` is the `schemaLocation` attribute value, which may be - /// an absolute URI or a relative path. `base` is the URI of the - /// including/importing schema, if known, for resolving relative paths. - /// - /// Returns `Some(xml_content)` if the schema was found, or `None` if - /// the schema cannot be resolved. - fn resolve(&self, location: &str, base: Option<&str>) -> Option<String>; -} - -impl<F> SchemaResolver for F -where - F: Fn(&str, Option<&str>) -> Option<String>, -{ - fn resolve(&self, location: &str, base: Option<&str>) -> Option<String> { - self(location, base) - } -} - -/// Options for parsing XSD schemas with multi-file schema composition. -/// -/// See XSD 1.0 section 4.2 for `xsd:include` and `xsd:import`. -pub struct XsdParseOptions<'a> { - /// Optional resolver for `xsd:include` and `xsd:import` directives. - /// - /// If `None`, include/import directives are silently ignored (matching - /// the current behavior of [`parse_xsd`]). - pub resolver: Option<&'a dyn SchemaResolver>, - - /// Optional base URI for resolving relative `schemaLocation` values. - pub base_uri: Option<String>, -} - -// --------------------------------------------------------------------------- -// Data model -// --------------------------------------------------------------------------- - -/// A parsed XML Schema definition. -/// -/// Contains all top-level declarations extracted from an `<xs:schema>` document: -/// global element declarations, named type definitions, and attribute groups. -#[derive(Debug, Clone)] -pub struct XsdSchema { - /// The target namespace of the schema, if declared. - pub target_namespace: Option<String>, - /// Global element declarations, keyed by element name. - elements: HashMap<String, XsdElement>, - /// Named type definitions (both simple and complex), keyed by type name. - types: HashMap<String, XsdType>, - /// Named attribute groups, keyed by group name. - attribute_groups: HashMap<String, Vec<XsdAttribute>>, - /// Imported schemas from other namespaces, keyed by namespace URI. - imported_namespaces: HashMap<String, ImportedSchema>, - /// Prefix-to-namespace-URI map from the root schema element. - /// - /// Used during validation to resolve `QName` type references like - /// `tns:AddressType` to the correct namespace for imported type lookup. - prefix_map: HashMap<String, String>, - /// The `elementFormDefault` attribute from the schema root. - /// - /// When `Qualified`, local element declarations must be namespace-qualified - /// in instance documents. Default is `Unqualified`. - /// - /// See XSD 1.0 section 3.3.2. - element_form_default: FormDefault, -} - -/// Whether local elements/attributes must be namespace-qualified in instances. -/// -/// See XSD 1.0 section 3.3.2. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FormDefault { - /// Local elements do not need to be namespace-qualified (default). - Unqualified, - /// Local elements must be namespace-qualified in instance documents. - Qualified, -} - -/// Declarations imported from another namespace via `xsd:import`. -/// -/// See XSD 1.0 section 4.2.3. -#[derive(Debug, Clone)] -struct ImportedSchema { - /// Global element declarations from the imported namespace. - elements: HashMap<String, XsdElement>, - /// Named type definitions from the imported namespace. - types: HashMap<String, XsdType>, - /// Named attribute groups from the imported namespace. - attribute_groups: HashMap<String, Vec<XsdAttribute>>, -} - -/// An element declaration in the schema. -/// -/// Elements can reference a named type via `type_ref`, define an inline type, -/// or default to `xs:anyType` if neither is specified. -/// -/// See XSD 1.0 section 3.3: Element Declarations. -#[derive(Debug, Clone)] -pub struct XsdElement { - /// The element name. - name: String, - /// Reference to a named type (e.g., `"xs:string"` or a user-defined name). - type_ref: Option<String>, - /// An inline anonymous type definition. - inline_type: Option<XsdType>, - /// Reference to a global element declaration (`ref` attribute `QName`). - /// - /// When present, the element's type is resolved from the referenced - /// global element declaration rather than from `type_ref` or `inline_type`. - element_ref: Option<String>, - /// Minimum number of occurrences (default 1 for local elements). - min_occurs: u32, - /// Maximum number of occurrences (default 1 for local elements). - max_occurs: MaxOccurs, -} - -/// Maximum occurrence constraint for particles. -/// -/// Can be a concrete bound or unbounded (no upper limit). -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum MaxOccurs { - /// A concrete upper bound. - Bounded(u32), - /// No upper limit (corresponds to `maxOccurs="unbounded"`). - Unbounded, -} - -impl std::fmt::Display for MaxOccurs { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Bounded(n) => write!(f, "{n}"), - Self::Unbounded => write!(f, "unbounded"), - } - } -} - -/// A type definition, either simple or complex. -/// -/// See XSD 1.0 section 3.4 (Complex Type) and 3.14 (Simple Type). -#[derive(Debug, Clone)] -pub enum XsdType { - /// A simple type for text-only content and attribute values. - Simple(SimpleType), - /// A complex type that can contain elements, attributes, and mixed content. - Complex(ComplexType), -} - -/// A simple type definition for text content and attribute values. -/// -/// Simple types constrain the textual content of elements and attributes. -/// They are defined by restriction, list, or union derivation. -/// -/// See XSD 1.0 section 3.14: Simple Type Definitions. -#[derive(Debug, Clone)] -pub struct SimpleType { - /// The type name, if this is a named (non-anonymous) type. - name: Option<String>, - /// The variety of the simple type. - variety: SimpleTypeVariety, -} - -/// The variety (derivation method) of a simple type. -#[derive(Debug, Clone)] -pub enum SimpleTypeVariety { - /// A restriction on a base type, optionally with constraining facets. - Restriction { - /// The base type name being restricted. - base: String, - /// Facets that further constrain the value space. - facets: Vec<Facet>, - }, - /// A list type whose items are whitespace-separated values of the item type. - List { - /// The name of the type for list items. - item_type: String, - }, - /// A union of multiple simple types. - Union { - /// The member type names. - member_types: Vec<String>, - }, - /// A reference to a built-in type by name. - Builtin(String), -} - -/// A constraining facet on a simple type restriction. -/// -/// See XSD 1.0 section 4.3: Constraining Facets. -#[derive(Debug, Clone)] -pub enum Facet { - /// Minimum number of characters / list items. - MinLength(usize), - /// Maximum number of characters / list items. - MaxLength(usize), - /// Exact number of characters / list items. - Length(usize), - /// A regular expression pattern the value must match. - Pattern(String), - /// An enumeration of allowed values. - Enumeration(Vec<String>), - /// Inclusive lower bound for ordered values. - MinInclusive(String), - /// Inclusive upper bound for ordered values. - MaxInclusive(String), - /// Exclusive lower bound for ordered values. - MinExclusive(String), - /// Exclusive upper bound for ordered values. - MaxExclusive(String), - /// Whitespace normalization rule. - WhiteSpace(WhiteSpaceValue), - /// Maximum total number of digits for decimal types. - TotalDigits(usize), - /// Maximum number of fractional digits for decimal types. - FractionDigits(usize), -} - -/// Whitespace normalization mode for simple type values. -/// -/// See XSD 1.0 section 4.3.6. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum WhiteSpaceValue { - /// Preserve all whitespace characters as-is. - Preserve, - /// Replace all occurrences of tab, line feed, and carriage return with space. - Replace, - /// After replacing, collapse contiguous sequences of spaces into one - /// and strip leading/trailing spaces. - Collapse, -} - -/// A complex type definition for structured content. -/// -/// Complex types describe elements that may contain child elements, -/// attributes, and optionally mixed text content. -/// -/// See XSD 1.0 section 3.4: Complex Type Definitions. -#[derive(Debug, Clone)] -pub struct ComplexType { - /// The type name, if this is a named (non-anonymous) type. - name: Option<String>, - /// The content model of the complex type. - content: ComplexContent, - /// Attribute declarations on elements of this type. - attributes: Vec<XsdAttribute>, - /// Whether the type allows mixed content (text interspersed with elements). - mixed: bool, -} - -/// The content model of a complex type. -#[derive(Debug, Clone)] -pub enum ComplexContent { - /// No child elements or text content allowed. - Empty, - /// An ordered sequence of particles, all of which must appear in order. - Sequence(Vec<XsdParticle>), - /// A choice among particles, exactly one of which must appear. - Choice(Vec<XsdParticle>), - /// An unordered collection where each particle may appear at most once. - All(Vec<XsdParticle>), - /// Simple content (text only) derived from a base type. - SimpleContent { - /// The base type name. - base: String, - }, -} - -/// A particle in a content model -- either an element or a nested group. -#[derive(Debug, Clone)] -pub enum XsdParticle { - /// An element declaration within the content model. - Element(XsdElement), - /// A nested compositor group (sequence, choice, or all). - Group(ComplexContent), -} - -/// An attribute declaration. -/// -/// See XSD 1.0 section 3.2: Attribute Declarations. -#[derive(Debug, Clone)] -pub struct XsdAttribute { - /// The attribute name. - name: String, - /// Reference to the attribute's type (e.g., `"xs:string"`). - type_ref: String, - /// Whether the attribute is required (`use="required"`). - required: bool, - /// Fixed value that the attribute must have if present. - fixed: Option<String>, -} - -// --------------------------------------------------------------------------- -// Schema parser -// --------------------------------------------------------------------------- - -/// Parses an XSD schema from its XML text representation. -/// -/// The input should be a well-formed XML document with an `<xs:schema>` root -/// element using the XML Schema namespace -/// (`http://www.w3.org/2001/XMLSchema`). -/// -/// This is a convenience wrapper around [`parse_xsd_with_options`] that does -/// not resolve `xsd:include` or `xsd:import` directives (they are silently -/// ignored). -/// -/// # Errors -/// -/// Returns a [`ValidationError`] if the input cannot be parsed as XML or -/// does not contain a valid XSD schema structure. -/// -/// # Examples -/// -/// ``` -/// use xmloxide::validation::xsd::parse_xsd; -/// -/// let schema = parse_xsd(r#" -/// <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> -/// <xs:element name="root" type="xs:string"/> -/// </xs:schema> -/// "#).unwrap(); -/// ``` -pub fn parse_xsd(schema_xml: &str) -> Result<XsdSchema, ValidationError> { - parse_xsd_with_options( - schema_xml, - &XsdParseOptions { - resolver: None, - base_uri: None, - }, - ) -} - -/// Parses an XSD schema with support for `xsd:include` and `xsd:import`. -/// -/// When a [`SchemaResolver`] is provided in the options, `xsd:include` and -/// `xsd:import` elements trigger loading and merging of referenced schemas. -/// -/// See XSD 1.0 section 4.2 for schema composition rules. -/// -/// # Errors -/// -/// Returns a [`ValidationError`] if the input cannot be parsed as XML, does -/// not contain a valid XSD schema structure, or if an included/imported -/// schema cannot be resolved or has a namespace mismatch. -/// -/// # Examples -/// -/// ``` -/// use xmloxide::validation::xsd::{parse_xsd_with_options, SchemaResolver, XsdParseOptions}; -/// -/// // A simple resolver that returns schema content by location -/// let resolver = |location: &str, _base: Option<&str>| -> Option<String> { -/// match location { -/// "types.xsd" => Some(r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> -/// <xs:complexType name="NameType"><xs:sequence> -/// <xs:element name="first" type="xs:string"/> -/// </xs:sequence></xs:complexType> -/// </xs:schema>"#.to_string()), -/// _ => None, -/// } -/// }; -/// -/// let opts = XsdParseOptions { -/// resolver: Some(&resolver), -/// base_uri: None, -/// }; -/// -/// let schema = parse_xsd_with_options(r#" -/// <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> -/// <xs:include schemaLocation="types.xsd"/> -/// <xs:element name="name" type="NameType"/> -/// </xs:schema> -/// "#, &opts).unwrap(); -/// ``` -pub fn parse_xsd_with_options( - schema_xml: &str, - options: &XsdParseOptions<'_>, -) -> Result<XsdSchema, ValidationError> { - // Parse the root schema document first to extract the prefix map - let root_doc = Document::parse_str(schema_xml).map_err(|e| ValidationError { - message: format!("failed to parse XSD schema XML: {e}"), - line: None, - column: None, - })?; - let root_elem = root_doc.root_element().ok_or_else(|| ValidationError { - message: "XSD schema has no root element".to_string(), - line: None, - column: None, - })?; - let prefix_map = build_prefix_map(&root_doc, root_elem); - let element_form_default = match root_doc.attribute(root_elem, "elementFormDefault") { - Some("qualified") => FormDefault::Qualified, - _ => FormDefault::Unqualified, - }; - - let mut schema = XsdSchema { - target_namespace: None, - elements: HashMap::new(), - types: HashMap::new(), - attribute_groups: HashMap::new(), - imported_namespaces: HashMap::new(), - prefix_map, - element_form_default, - }; - - register_builtin_types(&mut schema); - - let mut loaded = HashSet::new(); - // Use a synthetic key for the top-level schema (it has no schemaLocation) - loaded.insert("<root>".to_string()); - - parse_xsd_internal(schema_xml, options, &mut loaded, &mut schema)?; - - Ok(schema) -} - -/// Internal recursive schema parser with cycle detection. -fn parse_xsd_internal( - schema_xml: &str, - options: &XsdParseOptions<'_>, - loaded: &mut HashSet<String>, - schema: &mut XsdSchema, -) -> Result<(), ValidationError> { - let doc = Document::parse_str(schema_xml).map_err(|e| ValidationError { - message: format!("failed to parse XSD schema XML: {e}"), - line: None, - column: None, - })?; - - let root = doc.root_element().ok_or_else(|| ValidationError { - message: "XSD schema has no root element".to_string(), - line: None, - column: None, - })?; - - let root_name = doc.node_name(root).unwrap_or(""); - if root_name != "schema" { - return Err(ValidationError { - message: format!("expected <xs:schema> root element, found <{root_name}>"), - line: None, - column: None, - }); - } - - let this_ns = doc.attribute(root, "targetNamespace").map(String::from); - - // Set target_namespace from the first schema we parse (the root) - if schema.target_namespace.is_none() && this_ns.is_some() { - schema.target_namespace.clone_from(&this_ns); - } - - parse_top_level_declarations(&doc, root, schema, options, loaded, this_ns.as_ref())?; - - Ok(()) -} - -/// Parses top-level declarations from the schema root element. -fn parse_top_level_declarations( - doc: &Document, - root: NodeId, - schema: &mut XsdSchema, - options: &XsdParseOptions<'_>, - loaded: &mut HashSet<String>, - this_ns: Option<&String>, -) -> Result<(), ValidationError> { - for child in doc.children(root) { - let Some(name) = doc.node_name(child) else { - continue; - }; - match name { - "element" => { - if let Some(elem) = parse_element_decl(doc, child) { - schema.elements.insert(elem.name.clone(), elem); - } - } - "complexType" => { - let ct = parse_complex_type(doc, child); - if let Some(ref type_name) = ct.name { - schema.types.insert(type_name.clone(), XsdType::Complex(ct)); - } - } - "simpleType" => { - let st = parse_simple_type(doc, child); - if let Some(ref type_name) = st.name { - schema.types.insert(type_name.clone(), XsdType::Simple(st)); - } - } - "attributeGroup" => { - if let Some(group_name) = doc.attribute(child, "name") { - let attrs = parse_attributes(doc, child); - schema - .attribute_groups - .insert(group_name.to_string(), attrs); - } - } - "include" => { - handle_include(doc, child, schema, options, loaded, this_ns)?; - } - "import" => { - handle_import(doc, child, schema, options, loaded)?; - } - _ => {} - } - } - Ok(()) -} - -/// Handles an `<xsd:include>` element by resolving and merging the included -/// schema into the current schema. -/// -/// See XSD 1.0 section 4.2.1. -fn handle_include( - doc: &Document, - node: NodeId, - schema: &mut XsdSchema, - options: &XsdParseOptions<'_>, - loaded: &mut HashSet<String>, - this_ns: Option<&String>, -) -> Result<(), ValidationError> { - let Some(location) = doc.attribute(node, "schemaLocation") else { - return Ok(()); - }; - - // Cycle detection - if loaded.contains(location) { - return Ok(()); - } - - let Some(resolver) = options.resolver else { - return Ok(()); - }; - - let content = resolver - .resolve(location, options.base_uri.as_deref()) - .ok_or_else(|| ValidationError { - message: format!("cannot resolve included schema: {location}"), - line: None, - column: None, - })?; - - // Check namespace compatibility before merging: parse just the root to - // extract its targetNamespace. - let included_doc = Document::parse_str(&content).map_err(|e| ValidationError { - message: format!("failed to parse included schema '{location}': {e}"), - line: None, - column: None, - })?; - let included_root = included_doc.root_element().ok_or_else(|| ValidationError { - message: format!("included schema '{location}' has no root element"), - line: None, - column: None, - })?; - let included_ns = included_doc - .attribute(included_root, "targetNamespace") - .map(String::from); - - // Per XSD 1.0 §4.2.1: included schema must have the same targetNamespace - // or no targetNamespace (chameleon include). - if let Some(ref inc_ns) = included_ns { - if this_ns != Some(inc_ns) { - return Err(ValidationError { - message: format!( - "included schema '{location}' has targetNamespace '{inc_ns}' \ - which does not match the including schema's namespace" - ), - line: None, - column: None, - }); - } - } - - // Mark as loaded before recursing to prevent cycles - loaded.insert(location.to_string()); - - // Parse and merge the included schema's declarations - parse_xsd_internal(&content, options, loaded, schema)?; - - Ok(()) -} - -/// Handles an `<xsd:import>` element by resolving the imported schema and -/// storing its declarations under the imported namespace. -/// -/// See XSD 1.0 section 4.2.3. -fn handle_import( - doc: &Document, - node: NodeId, - schema: &mut XsdSchema, - options: &XsdParseOptions<'_>, - loaded: &mut HashSet<String>, -) -> Result<(), ValidationError> { - let namespace = doc.attribute(node, "namespace").map(String::from); - let location = doc.attribute(node, "schemaLocation"); - - let Some(location) = location else { - // Import without schemaLocation is valid — just declares the namespace - return Ok(()); - }; - - // Cycle detection - if loaded.contains(location) { - return Ok(()); - } - - let Some(resolver) = options.resolver else { - return Ok(()); - }; - - let content = resolver - .resolve(location, options.base_uri.as_deref()) - .ok_or_else(|| ValidationError { - message: format!("cannot resolve imported schema: {location}"), - line: None, - column: None, - })?; - - // Parse the imported schema to extract its declarations - let imported_doc = Document::parse_str(&content).map_err(|e| ValidationError { - message: format!("failed to parse imported schema '{location}': {e}"), - line: None, - column: None, - })?; - let imported_root = imported_doc.root_element().ok_or_else(|| ValidationError { - message: format!("imported schema '{location}' has no root element"), - line: None, - column: None, - })?; - - let imported_root_name = imported_doc.node_name(imported_root).unwrap_or(""); - if imported_root_name != "schema" { - return Err(ValidationError { - message: format!( - "imported schema '{location}' has root <{imported_root_name}>, expected <xs:schema>" - ), - line: None, - column: None, - }); - } - - let imported_ns = imported_doc - .attribute(imported_root, "targetNamespace") - .map(String::from); - - // Verify namespace matches if both are specified - if let (Some(ref expected), Some(ref actual)) = (&namespace, &imported_ns) { - if expected != actual { - return Err(ValidationError { - message: format!( - "imported schema '{location}' has targetNamespace '{actual}' \ - but import declares namespace '{expected}'" - ), - line: None, - column: None, - }); - } - } - - let ns_key = namespace.or(imported_ns).unwrap_or_default(); - - // Mark as loaded before recursing - loaded.insert(location.to_string()); - - // Build an ImportedSchema by parsing the imported schema's declarations - let mut imported = ImportedSchema { - elements: HashMap::new(), - types: HashMap::new(), - attribute_groups: HashMap::new(), - }; - - // We need a temporary XsdSchema to parse into, then extract declarations - let imported_form_default = match imported_doc.attribute(imported_root, "elementFormDefault") { - Some("qualified") => FormDefault::Qualified, - _ => FormDefault::Unqualified, - }; - let mut temp_schema = XsdSchema { - target_namespace: Some(ns_key.clone()), - elements: HashMap::new(), - types: HashMap::new(), - attribute_groups: HashMap::new(), - imported_namespaces: HashMap::new(), - prefix_map: build_prefix_map(&imported_doc, imported_root), - element_form_default: imported_form_default, - }; - register_builtin_types(&mut temp_schema); - parse_top_level_declarations( - &imported_doc, - imported_root, - &mut temp_schema, - options, - loaded, - Some(&ns_key), - )?; - - // Move non-builtin declarations to the ImportedSchema - for (name, typ) in &temp_schema.types { - // Skip built-in types — they are already registered on the main schema - if matches!(typ, XsdType::Simple(st) if matches!(st.variety, SimpleTypeVariety::Builtin(_))) - { - continue; - } - imported.types.insert(name.clone(), typ.clone()); - } - imported.elements = temp_schema.elements; - imported.attribute_groups = temp_schema.attribute_groups; - - // Also merge any transitive imports - for (k, v) in temp_schema.imported_namespaces { - schema.imported_namespaces.entry(k).or_insert(v); - } - - schema.imported_namespaces.entry(ns_key).or_insert(imported); - - Ok(()) -} - -/// Registers all supported built-in XSD types in the schema. -fn register_builtin_types(schema: &mut XsdSchema) { - let builtins = [ - "string", - "normalizedString", - "token", - "integer", - "int", - "long", - "short", - "byte", - "positiveInteger", - "nonNegativeInteger", - "negativeInteger", - "nonPositiveInteger", - "unsignedInt", - "unsignedLong", - "unsignedShort", - "unsignedByte", - "decimal", - "float", - "double", - "boolean", - "date", - "dateTime", - "time", - "anyURI", - "ID", - "IDREF", - "NMTOKEN", - "anyType", - "anySimpleType", - ]; - for name in builtins { - schema.types.insert( - name.to_string(), - XsdType::Simple(SimpleType { - name: Some(name.to_string()), - variety: SimpleTypeVariety::Builtin(name.to_string()), - }), - ); - } -} - -/// Parses an `<xs:element>` declaration. -/// -/// Handles both named declarations (`name="foo" type="xs:string"`) and -/// element references (`ref="cbc:ID"`). For references, the `ref` `QName` -/// is stored in `element_ref` and the local name is used as the element -/// name for matching. -fn parse_element_decl(doc: &Document, node: NodeId) -> Option<XsdElement> { - let min_occurs = doc - .attribute(node, "minOccurs") - .and_then(|v| v.parse::<u32>().ok()) - .unwrap_or(1); - let max_occurs = doc - .attribute(node, "maxOccurs") - .map_or(MaxOccurs::Bounded(1), |v| { - if v == "unbounded" { - MaxOccurs::Unbounded - } else { - MaxOccurs::Bounded(v.parse::<u32>().unwrap_or(1)) - } - }); - - // Handle ref="prefix:name" — reference to a global element declaration - if let Some(ref_qname) = doc.attribute(node, "ref") { - let local_name = if let Some((_prefix, local)) = ref_qname.split_once(':') { - local.to_string() - } else { - ref_qname.to_string() - }; - return Some(XsdElement { - name: local_name, - type_ref: None, - inline_type: None, - element_ref: Some(ref_qname.to_string()), - min_occurs, - max_occurs, - }); - } - - let name = doc.attribute(node, "name")?.to_string(); - let type_ref = doc.attribute(node, "type").map(strip_xs_prefix); - let inline_type = find_inline_type(doc, node); - Some(XsdElement { - name, - type_ref, - inline_type, - element_ref: None, - min_occurs, - max_occurs, - }) -} - -/// Looks for an inline `<xs:complexType>` or `<xs:simpleType>` child. -fn find_inline_type(doc: &Document, node: NodeId) -> Option<XsdType> { - for child in doc.children(node) { - let Some(child_name) = doc.node_name(child) else { - continue; - }; - match child_name { - "complexType" => return Some(XsdType::Complex(parse_complex_type(doc, child))), - "simpleType" => { - return Some(XsdType::Simple(parse_simple_type(doc, child))); - } - _ => {} - } - } - None -} - -/// Parses an `<xs:complexType>` element. -fn parse_complex_type(doc: &Document, node: NodeId) -> ComplexType { - let name = doc.attribute(node, "name").map(String::from); - let mixed = doc.attribute(node, "mixed") == Some("true"); - let mut content = ComplexContent::Empty; - let mut attributes = Vec::new(); - - for child in doc.children(node) { - let Some(child_name) = doc.node_name(child) else { - continue; - }; - match child_name { - "sequence" => content = parse_compositor(doc, child, CompositorKind::Sequence), - "choice" => content = parse_compositor(doc, child, CompositorKind::Choice), - "all" => content = parse_compositor(doc, child, CompositorKind::All), - "attribute" => { - if let Some(attr) = parse_attribute_decl(doc, child) { - attributes.push(attr); - } - } - "simpleContent" => { - content = parse_simple_content(doc, child); - collect_simple_content_attributes(doc, child, &mut attributes); - } - _ => {} - } - } - ComplexType { - name, - content, - attributes, - mixed, - } -} - -/// Collects attribute declarations from `<xs:simpleContent>` extension children. -fn collect_simple_content_attributes( - doc: &Document, - sc_node: NodeId, - attributes: &mut Vec<XsdAttribute>, -) { - for sc_child in doc.children(sc_node) { - if doc.node_name(sc_child) == Some("extension") { - for ext_child in doc.children(sc_child) { - if doc.node_name(ext_child) == Some("attribute") { - if let Some(attr) = parse_attribute_decl(doc, ext_child) { - attributes.push(attr); - } - } - } - } - } -} - -/// Compositor kind for parsing content model groups. -#[derive(Clone, Copy)] -enum CompositorKind { - Sequence, - Choice, - All, -} - -/// Parses a compositor (`<xs:sequence>`, `<xs:choice>`, or `<xs:all>`). -fn parse_compositor(doc: &Document, node: NodeId, kind: CompositorKind) -> ComplexContent { - let mut particles = Vec::new(); - for child in doc.children(node) { - let Some(child_name) = doc.node_name(child) else { - continue; - }; - match child_name { - "element" => { - if let Some(elem) = parse_element_decl(doc, child) { - particles.push(XsdParticle::Element(elem)); - } - } - "sequence" => { - particles.push(XsdParticle::Group(parse_compositor( - doc, - child, - CompositorKind::Sequence, - ))); - } - "choice" => { - particles.push(XsdParticle::Group(parse_compositor( - doc, - child, - CompositorKind::Choice, - ))); - } - "all" => { - particles.push(XsdParticle::Group(parse_compositor( - doc, - child, - CompositorKind::All, - ))); - } - _ => {} - } - } - match kind { - CompositorKind::Sequence => ComplexContent::Sequence(particles), - CompositorKind::Choice => ComplexContent::Choice(particles), - CompositorKind::All => ComplexContent::All(particles), - } -} - -/// Parses `<xs:simpleContent>` within a complex type. -fn parse_simple_content(doc: &Document, node: NodeId) -> ComplexContent { - for child in doc.children(node) { - if matches!(doc.node_name(child), Some("extension" | "restriction")) { - if let Some(base) = doc.attribute(child, "base") { - return ComplexContent::SimpleContent { - base: strip_xs_prefix(base), - }; - } - } - } - ComplexContent::Empty -} - -/// Parses an `<xs:simpleType>` element. -fn parse_simple_type(doc: &Document, node: NodeId) -> SimpleType { - let name = doc.attribute(node, "name").map(String::from); - for child in doc.children(node) { - let Some(child_name) = doc.node_name(child) else { - continue; - }; - match child_name { - "restriction" => { - let base = doc - .attribute(child, "base") - .map_or_else(|| "string".to_string(), strip_xs_prefix); - let facets = parse_facets(doc, child); - return SimpleType { - name, - variety: SimpleTypeVariety::Restriction { base, facets }, - }; - } - "list" => { - let item_type = doc - .attribute(child, "itemType") - .map_or_else(|| "string".to_string(), strip_xs_prefix); - return SimpleType { - name, - variety: SimpleTypeVariety::List { item_type }, - }; - } - "union" => { - let member_types = doc - .attribute(child, "memberTypes") - .map_or_else(Vec::new, |mt| { - mt.split_whitespace().map(strip_xs_prefix).collect() - }); - return SimpleType { - name, - variety: SimpleTypeVariety::Union { member_types }, - }; - } - _ => {} - } - } - SimpleType { - name, - variety: SimpleTypeVariety::Builtin("string".to_string()), - } -} - -/// Parses facet children from an `<xs:restriction>` element. -fn parse_facets(doc: &Document, restriction_node: NodeId) -> Vec<Facet> { - let mut facets = Vec::new(); - let mut enumerations = Vec::new(); - for child in doc.children(restriction_node) { - let Some(child_name) = doc.node_name(child) else { - continue; - }; - let Some(value) = doc.attribute(child, "value") else { - continue; - }; - match child_name { - "minLength" => { - if let Ok(n) = value.parse::<usize>() { - facets.push(Facet::MinLength(n)); - } - } - "maxLength" => { - if let Ok(n) = value.parse::<usize>() { - facets.push(Facet::MaxLength(n)); - } - } - "length" => { - if let Ok(n) = value.parse::<usize>() { - facets.push(Facet::Length(n)); - } - } - "pattern" => facets.push(Facet::Pattern(value.to_string())), - "enumeration" => enumerations.push(value.to_string()), - "minInclusive" => facets.push(Facet::MinInclusive(value.to_string())), - "maxInclusive" => facets.push(Facet::MaxInclusive(value.to_string())), - "minExclusive" => facets.push(Facet::MinExclusive(value.to_string())), - "maxExclusive" => facets.push(Facet::MaxExclusive(value.to_string())), - "whiteSpace" => { - let ws = match value { - "replace" => WhiteSpaceValue::Replace, - "collapse" => WhiteSpaceValue::Collapse, - _ => WhiteSpaceValue::Preserve, - }; - facets.push(Facet::WhiteSpace(ws)); - } - "totalDigits" => { - if let Ok(n) = value.parse::<usize>() { - facets.push(Facet::TotalDigits(n)); - } - } - "fractionDigits" => { - if let Ok(n) = value.parse::<usize>() { - facets.push(Facet::FractionDigits(n)); - } - } - _ => {} - } - } - if !enumerations.is_empty() { - facets.push(Facet::Enumeration(enumerations)); - } - facets -} - -/// Parses an `<xs:attribute>` declaration. -fn parse_attribute_decl(doc: &Document, node: NodeId) -> Option<XsdAttribute> { - let name = doc.attribute(node, "name")?.to_string(); - let type_ref = doc - .attribute(node, "type") - .map_or_else(|| "string".to_string(), strip_xs_prefix); - let required = doc.attribute(node, "use") == Some("required"); - let fixed = doc.attribute(node, "fixed").map(String::from); - Some(XsdAttribute { - name, - type_ref, - required, - fixed, - }) -} - -/// Parses all `<xs:attribute>` children of a given node. -fn parse_attributes(doc: &Document, node: NodeId) -> Vec<XsdAttribute> { - doc.children(node) - .filter(|&c| doc.node_name(c) == Some("attribute")) - .filter_map(|c| parse_attribute_decl(doc, c)) - .collect() -} - -/// Builds a prefix-to-namespace-URI map from `xmlns:*` attributes on a node. -/// -/// Scans the attributes of the given node for namespace declarations -/// (`xmlns:prefix="uri"`) and returns a map from prefix to URI. -fn build_prefix_map(doc: &Document, node: NodeId) -> HashMap<String, String> { - let mut map = HashMap::new(); - for attr in doc.attributes(node) { - if attr.prefix.as_deref() == Some("xmlns") { - map.insert(attr.name.clone(), attr.value.clone()); - } - } - map -} - -/// Resolves a `QName` type reference into a namespace URI and local name. -/// -/// Given a type reference like `"xs:string"` or `"tns:AddressType"`, splits -/// on `:` and looks up the prefix in the provided prefix map to get the -/// namespace URI. -/// -/// Returns `(None, local_name)` for unprefixed names and -/// `(Some(namespace_uri), local_name)` for prefixed names. -fn resolve_type_qname( - qname: &str, - prefix_map: &HashMap<String, String>, -) -> (Option<String>, String) { - if let Some((prefix, local)) = qname.split_once(':') { - let ns = prefix_map.get(prefix).cloned(); - (ns, local.to_string()) - } else { - (None, qname.to_string()) - } -} - -/// Strips an `xs:` or `xsd:` prefix from a type reference string. -fn strip_xs_prefix(name: &str) -> String { - if let Some(local) = name.strip_prefix("xs:") { - local.to_string() - } else if let Some(local) = name.strip_prefix("xsd:") { - local.to_string() - } else { - name.to_string() - } -} - -// --------------------------------------------------------------------------- -// Validator -// --------------------------------------------------------------------------- - -/// Validates an XML document against an XSD schema. -/// -/// Walks the document tree starting from the root element, matching elements -/// against their declarations in the schema, checking content models, -/// attribute constraints, and simple type facets. -/// -/// # Examples -/// -/// ``` -/// use xmloxide::Document; -/// use xmloxide::validation::xsd::{parse_xsd, validate_xsd}; -/// -/// let schema = parse_xsd(r#" -/// <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> -/// <xs:element name="note" type="xs:string"/> -/// </xs:schema> -/// "#).unwrap(); -/// -/// let doc = Document::parse_str("<note>Hello</note>").unwrap(); -/// let result = validate_xsd(&doc, &schema); -/// assert!(result.is_valid); -/// ``` -pub fn validate_xsd(doc: &Document, schema: &XsdSchema) -> ValidationResult { - let mut errors = Vec::new(); - let Some(root) = doc.root_element() else { - errors.push(ValidationError { - message: "document has no root element".to_string(), - line: None, - column: None, - }); - return ValidationResult { - is_valid: false, - errors, - warnings: vec![], - }; - }; - let root_name = doc.node_name(root).unwrap_or(""); - if let Some(decl) = schema.elements.get(root_name) { - validate_element(doc, root, decl, schema, &mut errors); - } else { - errors.push(ValidationError { - message: format!( - "element <{root_name}> not declared as a global element in the schema" - ), - line: None, - column: None, - }); - } - ValidationResult { - is_valid: errors.is_empty(), - errors, - warnings: vec![], - } -} - -/// Validates a single element against its declaration. -fn validate_element( - doc: &Document, - node: NodeId, - decl: &XsdElement, - schema: &XsdSchema, - errors: &mut Vec<ValidationError>, -) { - match resolve_element_type(decl, schema) { - Some(XsdType::Complex(ct)) => validate_complex_element(doc, node, ct, schema, errors), - Some(XsdType::Simple(st)) => validate_simple_element(doc, node, st, schema, errors), - None => {} // anyType - } -} - -/// Resolves the type for an element declaration, checking both local types -/// and imported namespaces for QName-prefixed type references. -/// -/// For element references (`ref="cbc:ID"`), resolves the referenced global -/// element declaration and returns its type. -fn resolve_element_type<'a>(decl: &'a XsdElement, schema: &'a XsdSchema) -> Option<&'a XsdType> { - // Handle element ref — look up the referenced global element's type - if let Some(ref ref_qname) = decl.element_ref { - if let Some(ref_decl) = resolve_element_ref(ref_qname, schema) { - return resolve_element_type(ref_decl, schema); - } - return None; - } - if let Some(ref inline) = decl.inline_type { - return Some(inline); - } - if let Some(ref type_name) = decl.type_ref { - return resolve_type_name(type_name, schema); - } - None -} - -/// Resolves a type by name, checking local types first, then imported namespaces. -fn resolve_type_name<'a>(type_name: &str, schema: &'a XsdSchema) -> Option<&'a XsdType> { - // Try local types first (handles unprefixed names and xs:-stripped names) - if let Some(t) = schema.types.get(type_name) { - return Some(t); - } - // Try namespace-aware resolution for prefixed type references - let (ns, local) = resolve_type_qname(type_name, &schema.prefix_map); - if let Some(ref ns_uri) = ns { - if ns_uri == XSD_NAMESPACE { - // Built-in XSD type — look up by local name - return schema.types.get(&local); - } - // Check imported namespaces - if let Some(imported) = schema.imported_namespaces.get(ns_uri) { - return imported.types.get(&local); - } - } - None -} - -/// Resolves an element reference `QName` to its global element declaration. -/// -/// Checks local elements first, then imported namespaces for prefixed refs. -fn resolve_element_ref<'a>(ref_qname: &str, schema: &'a XsdSchema) -> Option<&'a XsdElement> { - // Unprefixed ref — look up in local elements - if !ref_qname.contains(':') { - return schema.elements.get(ref_qname); - } - // Prefixed ref — resolve namespace and look up in imported elements - let (ns, local) = resolve_type_qname(ref_qname, &schema.prefix_map); - if let Some(ref ns_uri) = ns { - if let Some(imported) = schema.imported_namespaces.get(ns_uri) { - return imported.elements.get(&local); - } - } - None -} - -/// Validates an element with a complex type. -fn validate_complex_element( - doc: &Document, - node: NodeId, - ct: &ComplexType, - schema: &XsdSchema, - errors: &mut Vec<ValidationError>, -) { - let elem_name = doc.node_name(node).unwrap_or("<unknown>"); - validate_attributes(doc, node, &ct.attributes, schema, errors); - match &ct.content { - ComplexContent::Empty => validate_empty_content(doc, node, elem_name, ct.mixed, errors), - ComplexContent::Sequence(p) => { - let ce = collect_child_elements(doc, node); - validate_sequence(doc, &ce, p, elem_name, schema, errors); - } - ComplexContent::Choice(p) => { - let ce = collect_child_elements(doc, node); - validate_choice(doc, &ce, p, elem_name, schema, errors); - } - ComplexContent::All(p) => { - let ce = collect_child_elements(doc, node); - validate_all(doc, &ce, p, elem_name, schema, errors); - } - ComplexContent::SimpleContent { base } => { - let text = doc.text_content(node); - if let Some(XsdType::Simple(st)) = schema.types.get(base.as_str()) { - validate_simple_value(&text, st, elem_name, schema, errors); - } - } - } -} - -/// Validates empty content model constraints. -fn validate_empty_content( - doc: &Document, - node: NodeId, - elem_name: &str, - mixed: bool, - errors: &mut Vec<ValidationError>, -) { - let has_children = doc - .children(node) - .any(|c| matches!(doc.node(c).kind, NodeKind::Element { .. })); - if has_children { - errors.push(ValidationError { - message: format!( - "element <{elem_name}> has empty content model but contains child elements" - ), - line: None, - column: None, - }); - } - if !mixed && !doc.text_content(node).trim().is_empty() { - errors.push(ValidationError { - message: format!( - "element <{elem_name}> has empty content model but contains text content" - ), - line: None, - column: None, - }); - } -} - -/// Collects child element `NodeId`s. -fn collect_child_elements(doc: &Document, node: NodeId) -> Vec<NodeId> { - doc.children(node) - .filter(|&c| matches!(doc.node(c).kind, NodeKind::Element { .. })) - .collect() -} - -/// Validates a sequence content model. -fn validate_sequence( - doc: &Document, - children: &[NodeId], - particles: &[XsdParticle], - parent_name: &str, - schema: &XsdSchema, - errors: &mut Vec<ValidationError>, -) { - let mut idx = 0; - for particle in particles { - match particle { - XsdParticle::Element(decl) => { - idx += validate_sequence_element( - doc, - &children[idx..], - decl, - parent_name, - schema, - errors, - ); - } - XsdParticle::Group(content) => { - idx += validate_group_content( - doc, - &children[idx..], - content, - parent_name, - schema, - errors, - ); - } - } - } - if idx < children.len() { - let unexpected = doc.node_name(children[idx]).unwrap_or("<unknown>"); - errors.push(ValidationError { - message: format!("unexpected element <{unexpected}> in <{parent_name}>; not expected by the content model"), - line: None, column: None, - }); - } -} - -/// Validates a single element particle in a sequence, returning number consumed. -/// Checks if an instance element matches a schema element declaration, -/// accounting for `elementFormDefault` and element-level `form` attributes. -/// -/// When qualified form is in effect, the element must have the schema's -/// target namespace. When unqualified, the element is matched by local -/// name only (no namespace required). -fn element_matches_decl( - doc: &Document, - node: NodeId, - decl: &XsdElement, - schema: &XsdSchema, -) -> bool { - let child_name = doc.node_name(node).unwrap_or(""); - if child_name != decl.name { - return false; - } - // Check namespace qualification - if schema.element_form_default == FormDefault::Qualified { - if let Some(ref target_ns) = schema.target_namespace { - let child_ns = doc.node_namespace(node).unwrap_or(""); - return child_ns == target_ns; - } - } - true -} - -fn validate_sequence_element( - doc: &Document, - children: &[NodeId], - decl: &XsdElement, - parent_name: &str, - schema: &XsdSchema, - errors: &mut Vec<ValidationError>, -) -> usize { - let mut count: u32 = 0; - let mut consumed = 0; - for &child in children { - if !element_matches_decl(doc, child, decl, schema) { - break; - } - if let MaxOccurs::Bounded(max) = decl.max_occurs { - if count >= max { - break; - } - } - validate_element(doc, child, decl, schema, errors); - count += 1; - consumed += 1; - } - if count < decl.min_occurs { - errors.push(ValidationError { - message: format!( - "element <{parent_name}> requires at least {} occurrence(s) of <{}>, found {count}", - decl.min_occurs, decl.name - ), - line: None, - column: None, - }); - } - consumed -} - -/// Validates a nested group content model, returning children consumed. -fn validate_group_content( - doc: &Document, - children: &[NodeId], - content: &ComplexContent, - parent_name: &str, - schema: &XsdSchema, - errors: &mut Vec<ValidationError>, -) -> usize { - match content { - ComplexContent::Sequence(particles) => { - let before = errors.len(); - validate_sequence(doc, children, particles, parent_name, schema, errors); - if errors.len() == before { - children.len() - } else { - 0 - } - } - ComplexContent::Choice(particles) => { - validate_choice(doc, children, particles, parent_name, schema, errors); - usize::from(!children.is_empty()) - } - _ => 0, - } -} - -/// Validates a choice content model. -fn validate_choice( - doc: &Document, - children: &[NodeId], - particles: &[XsdParticle], - parent_name: &str, - schema: &XsdSchema, - errors: &mut Vec<ValidationError>, -) { - if children.is_empty() { - let any_optional = particles - .iter() - .any(|p| matches!(p, XsdParticle::Element(d) if d.min_occurs == 0)); - if !any_optional { - errors.push(ValidationError { - message: format!("element <{parent_name}> requires one of the choice alternatives but has no child elements"), - line: None, column: None, - }); - } - return; - } - let first = children[0]; - let first_name = doc.node_name(first).unwrap_or(""); - let matched = particles.iter().any(|p| { - if let XsdParticle::Element(decl) = p { - if element_matches_decl(doc, first, decl, schema) { - validate_element(doc, first, decl, schema, errors); - return true; - } - } - false - }); - if !matched { - let choices: Vec<&str> = particles - .iter() - .filter_map(|p| { - if let XsdParticle::Element(d) = p { - Some(d.name.as_str()) - } else { - None - } - }) - .collect(); - errors.push(ValidationError { - message: format!("element <{first_name}> in <{parent_name}> does not match any choice alternative; expected one of: {}", choices.join(", ")), - line: None, column: None, - }); - } -} - -/// Validates an `all` content model. -fn validate_all( - doc: &Document, - children: &[NodeId], - particles: &[XsdParticle], - parent_name: &str, - schema: &XsdSchema, - errors: &mut Vec<ValidationError>, -) { - let mut seen: HashMap<&str, u32> = HashMap::new(); - for &child in children { - let child_name = doc.node_name(child).unwrap_or(""); - let matching = particles.iter().find( - |p| matches!(p, XsdParticle::Element(d) if element_matches_decl(doc, child, d, schema)), - ); - if let Some(XsdParticle::Element(decl)) = matching { - let count = seen.entry(child_name).or_insert(0); - *count += 1; - if let MaxOccurs::Bounded(max) = decl.max_occurs { - if *count > max { - errors.push(ValidationError { - message: format!("element <{child_name}> in <{parent_name}> appears more than {max} time(s) in all group"), - line: None, column: None, - }); - } - } - validate_element(doc, child, decl, schema, errors); - } else { - errors.push(ValidationError { - message: format!("unexpected element <{child_name}> in <{parent_name}>; not declared in the all group"), - line: None, column: None, - }); - } - } - for particle in particles { - if let XsdParticle::Element(decl) = particle { - let count = seen.get(decl.name.as_str()).copied().unwrap_or(0); - if count < decl.min_occurs { - errors.push(ValidationError { - message: format!("element <{parent_name}> requires at least {} occurrence(s) of <{}> in the all group, found {count}", decl.min_occurs, decl.name), - line: None, column: None, - }); - } - } - } -} - -/// Validates an element with a simple type. -fn validate_simple_element( - doc: &Document, - node: NodeId, - st: &SimpleType, - schema: &XsdSchema, - errors: &mut Vec<ValidationError>, -) { - let elem_name = doc.node_name(node).unwrap_or("<unknown>"); - if doc - .children(node) - .any(|c| matches!(doc.node(c).kind, NodeKind::Element { .. })) - { - errors.push(ValidationError { - message: format!("element <{elem_name}> has simple type but contains child elements"), - line: None, - column: None, - }); - return; - } - validate_simple_value(&doc.text_content(node), st, elem_name, schema, errors); -} - -/// Validates a string value against a simple type definition. -fn validate_simple_value( - value: &str, - st: &SimpleType, - context: &str, - schema: &XsdSchema, - errors: &mut Vec<ValidationError>, -) { - match &st.variety { - SimpleTypeVariety::Builtin(name) => validate_builtin_value(value, name, context, errors), - SimpleTypeVariety::Restriction { base, facets } => { - if let Some(XsdType::Simple(bt)) = schema.types.get(base.as_str()) { - validate_simple_value(value, bt, context, schema, errors); - } else { - validate_builtin_value(value, base, context, errors); - } - validate_facets(value, facets, context, errors); - } - SimpleTypeVariety::List { item_type } => { - for item in value.split_whitespace() { - if let Some(XsdType::Simple(ist)) = schema.types.get(item_type.as_str()) { - validate_simple_value(item, ist, context, schema, errors); - } else { - validate_builtin_value(item, item_type, context, errors); - } - } - } - SimpleTypeVariety::Union { member_types } => { - validate_union_value(value, member_types, context, schema, errors); - } - } -} - -/// Validates a value against a union type. -fn validate_union_value( - value: &str, - member_types: &[String], - context: &str, - schema: &XsdSchema, - errors: &mut Vec<ValidationError>, -) { - let mut any_valid = false; - for mt in member_types { - let mut trial = Vec::new(); - if let Some(XsdType::Simple(mst)) = schema.types.get(mt.as_str()) { - validate_simple_value(value, mst, context, schema, &mut trial); - } else { - validate_builtin_value(value, mt, context, &mut trial); - } - if trial.is_empty() { - any_valid = true; - break; - } - } - if !any_valid && !member_types.is_empty() { - errors.push(ValidationError { - message: format!( - "value \"{value}\" in <{context}> does not match any member type of the union" - ), - line: None, - column: None, - }); - } -} - -/// Validates a value against a built-in XSD type. -#[allow(clippy::too_many_lines)] -fn validate_builtin_value( - value: &str, - type_name: &str, - context: &str, - errors: &mut Vec<ValidationError>, -) { - match type_name { - "integer" | "long" | "int" | "short" | "byte" => { - validate_signed_integer(value, type_name, context, errors); - } - "positiveInteger" => { - validate_constrained_integer(value, context, "positiveInteger", |n| n > 0, errors); - } - "nonNegativeInteger" => { - validate_constrained_integer(value, context, "nonNegativeInteger", |n| n >= 0, errors); - } - "negativeInteger" => { - validate_constrained_integer(value, context, "negativeInteger", |n| n < 0, errors); - } - "nonPositiveInteger" => { - validate_constrained_integer(value, context, "nonPositiveInteger", |n| n <= 0, errors); - } - "unsignedInt" | "unsignedLong" | "unsignedShort" | "unsignedByte" => { - validate_unsigned_integer(value, type_name, context, errors); - } - "decimal" if parse_decimal(value).is_none() => { - errors.push(ValidationError { - message: format!("value \"{value}\" in <{context}> is not a valid decimal"), - line: None, - column: None, - }); - } - "float" | "double" - if !matches!(value, "INF" | "-INF" | "NaN") && value.parse::<f64>().is_err() => - { - errors.push(ValidationError { - message: format!("value \"{value}\" in <{context}> is not a valid {type_name}"), - line: None, - column: None, - }); - } - "boolean" if !matches!(value, "true" | "false" | "1" | "0") => { - errors.push(ValidationError { - message: format!( - "value \"{value}\" in <{context}> is not a valid boolean (expected true, false, 1, or 0)" - ), - line: None, - column: None, - }); - } - "date" if !is_valid_date_pattern(value) => { - errors.push(ValidationError { - message: format!( - "value \"{value}\" in <{context}> is not a valid date (expected YYYY-MM-DD)" - ), - line: None, - column: None, - }); - } - "dateTime" if !is_valid_datetime_pattern(value) => { - errors.push(ValidationError { - message: format!("value \"{value}\" in <{context}> is not a valid dateTime"), - line: None, - column: None, - }); - } - "time" if !is_valid_time_pattern(value) => { - errors.push(ValidationError { - message: format!( - "value \"{value}\" in <{context}> is not a valid time (expected hh:mm:ss)" - ), - line: None, - column: None, - }); - } - _ => {} - } -} - -/// Validates and range-checks a signed integer value. -fn validate_signed_integer( - value: &str, - type_name: &str, - context: &str, - errors: &mut Vec<ValidationError>, -) { - if value.parse::<i64>().is_err() { - errors.push(ValidationError { - message: format!("value \"{value}\" in <{context}> is not a valid {type_name}"), - line: None, - column: None, - }); - return; - } - check_integer_range(value, type_name, context, errors); -} - -/// Validates a constrained integer (positive, negative, etc.). -fn validate_constrained_integer( - value: &str, - context: &str, - type_name: &str, - predicate: fn(i64) -> bool, - errors: &mut Vec<ValidationError>, -) { - match value.parse::<i64>() { - Ok(n) if predicate(n) => {} - _ => { - errors.push(ValidationError { - message: format!("value \"{value}\" in <{context}> is not a valid {type_name}"), - line: None, - column: None, - }); - } - } -} - -/// Validates and range-checks an unsigned integer value. -fn validate_unsigned_integer( - value: &str, - type_name: &str, - context: &str, - errors: &mut Vec<ValidationError>, -) { - if value.parse::<u64>().is_err() { - errors.push(ValidationError { - message: format!("value \"{value}\" in <{context}> is not a valid {type_name}"), - line: None, - column: None, - }); - return; - } - check_unsigned_range(value, type_name, context, errors); -} - -/// Checks range constraints for signed integer types. -fn check_integer_range( - value: &str, - type_name: &str, - context: &str, - errors: &mut Vec<ValidationError>, -) { - let Ok(n) = value.parse::<i64>() else { return }; - let (min, max) = match type_name { - "byte" => (i64::from(i8::MIN), i64::from(i8::MAX)), - "short" => (i64::from(i16::MIN), i64::from(i16::MAX)), - "int" => (i64::from(i32::MIN), i64::from(i32::MAX)), - "long" => (i64::MIN, i64::MAX), - _ => return, - }; - if n < min || n > max { - errors.push(ValidationError { - message: format!( - "value \"{value}\" in <{context}> is out of range for {type_name} ({min}..{max})" - ), - line: None, - column: None, - }); - } -} - -/// Checks range constraints for unsigned integer types. -fn check_unsigned_range( - value: &str, - type_name: &str, - context: &str, - errors: &mut Vec<ValidationError>, -) { - let Ok(n) = value.parse::<u64>() else { return }; - let max = match type_name { - "unsignedByte" => u64::from(u8::MAX), - "unsignedShort" => u64::from(u16::MAX), - "unsignedInt" => u64::from(u32::MAX), - "unsignedLong" => u64::MAX, - _ => return, - }; - if n > max { - errors.push(ValidationError { - message: format!( - "value \"{value}\" in <{context}> is out of range for {type_name} (0..{max})" - ), - line: None, - column: None, - }); - } -} - -/// Parses a decimal value. -fn parse_decimal(value: &str) -> Option<f64> { - let trimmed = value.trim(); - if trimmed.is_empty() || trimmed.contains('e') || trimmed.contains('E') { - return None; - } - trimmed.parse::<f64>().ok() -} - -/// Basic validation for `xs:date` pattern. -fn is_valid_date_pattern(value: &str) -> bool { - let date_part = strip_timezone(value); - if let Some(without_sign) = date_part.strip_prefix('-') { - let parts: Vec<&str> = without_sign.split('-').collect(); - return parts.len() == 3 - && parts[0].len() >= 4 - && parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())); - } - let parts: Vec<&str> = date_part.split('-').collect(); - parts.len() == 3 - && parts[0].len() >= 4 - && parts[0].chars().all(|c| c.is_ascii_digit()) - && parts[1].len() == 2 - && parts[1].chars().all(|c| c.is_ascii_digit()) - && parts[2].len() == 2 - && parts[2].chars().all(|c| c.is_ascii_digit()) -} - -/// Basic validation for `xs:dateTime` pattern. -fn is_valid_datetime_pattern(value: &str) -> bool { - let dt = strip_timezone(value); - let Some((date, time)) = dt.split_once('T') else { - return false; - }; - is_valid_date_pattern(date) && is_valid_time_pattern(time) -} - -/// Basic validation for `xs:time` pattern. -fn is_valid_time_pattern(value: &str) -> bool { - let time_part = strip_timezone(value); - let parts: Vec<&str> = time_part.split(':').collect(); - if parts.len() != 3 { - return false; - } - let sec = parts[2].split('.').next().unwrap_or(""); - parts[0].len() == 2 - && parts[0].chars().all(|c| c.is_ascii_digit()) - && parts[1].len() == 2 - && parts[1].chars().all(|c| c.is_ascii_digit()) - && !sec.is_empty() - && sec.chars().all(|c| c.is_ascii_digit()) -} - -/// Strips timezone suffix. -fn strip_timezone(value: &str) -> &str { - if let Some(s) = value.strip_suffix('Z') { - return s; - } - if value.len() > 6 { - let tail = &value[value.len() - 6..]; - if (tail.starts_with('+') || tail.starts_with('-')) && tail.as_bytes().get(3) == Some(&b':') - { - return &value[..value.len() - 6]; - } - } - value -} - -/// Applies whitespace normalization to a value according to the XSD `whiteSpace` facet. -/// -/// See XSD 1.0 section 4.3.6: -/// - `Preserve`: no normalization -/// - `Replace`: replace `\t`, `\n`, `\r` with space -/// - `Collapse`: replace + collapse contiguous spaces + strip leading/trailing -fn apply_whitespace_normalization(value: &str, ws: &WhiteSpaceValue) -> String { - match ws { - WhiteSpaceValue::Preserve => value.to_string(), - WhiteSpaceValue::Replace => value - .chars() - .map(|c| { - if matches!(c, '\t' | '\n' | '\r') { - ' ' - } else { - c - } - }) - .collect(), - WhiteSpaceValue::Collapse => { - let replaced: String = value - .chars() - .map(|c| { - if matches!(c, '\t' | '\n' | '\r') { - ' ' - } else { - c - } - }) - .collect(); - replaced.split_whitespace().collect::<Vec<_>>().join(" ") - } - } -} - -/// Validates facet constraints on a string value. -fn validate_facets( - value: &str, - facets: &[Facet], - context: &str, - errors: &mut Vec<ValidationError>, -) { - // Find any WhiteSpace facet and normalize the value before checking other facets. - let normalized; - let effective_value = if let Some(ws) = facets.iter().find_map(|f| { - if let Facet::WhiteSpace(ws) = f { - Some(ws) - } else { - None - } - }) { - normalized = apply_whitespace_normalization(value, ws); - &normalized - } else { - value - }; - - for facet in facets { - validate_single_facet(effective_value, facet, context, errors); - } -} - -/// Validates a single facet constraint. -#[allow(clippy::too_many_lines)] -fn validate_single_facet( - value: &str, - facet: &Facet, - context: &str, - errors: &mut Vec<ValidationError>, -) { - match facet { - Facet::MinLength(min) => { - if value.len() < *min { - errors.push(ValidationError { - message: format!( - "value in <{context}> has length {} but minLength is {min}", - value.len() - ), - line: None, - column: None, - }); - } - } - Facet::MaxLength(max) => { - if value.len() > *max { - errors.push(ValidationError { - message: format!( - "value in <{context}> has length {} but maxLength is {max}", - value.len() - ), - line: None, - column: None, - }); - } - } - Facet::Length(len) => { - if value.len() != *len { - errors.push(ValidationError { - message: format!( - "value in <{context}> has length {} but required length is {len}", - value.len() - ), - line: None, - column: None, - }); - } - } - Facet::Pattern(pattern) => { - if !matches_xsd_pattern(value, pattern) { - errors.push(ValidationError { - message: format!( - "value \"{value}\" in <{context}> does not match pattern \"{pattern}\"" - ), - line: None, - column: None, - }); - } - } - Facet::Enumeration(allowed) => { - if !allowed.iter().any(|a| a == value) { - errors.push(ValidationError { - message: format!( - "value \"{value}\" in <{context}> is not in the enumeration: {}", - allowed.join(", ") - ), - line: None, - column: None, - }); - } - } - Facet::MinInclusive(min) => { - if let (Some(v), Some(m)) = (parse_decimal(value), parse_decimal(min)) { - if v < m { - errors.push(ValidationError { - message: format!( - "value \"{value}\" in <{context}> is less than minInclusive {min}" - ), - line: None, - column: None, - }); - } - } - } - Facet::MaxInclusive(max) => { - if let (Some(v), Some(m)) = (parse_decimal(value), parse_decimal(max)) { - if v > m { - errors.push(ValidationError { - message: format!( - "value \"{value}\" in <{context}> is greater than maxInclusive {max}" - ), - line: None, - column: None, - }); - } - } - } - Facet::MinExclusive(min) => { - if let (Some(v), Some(m)) = (parse_decimal(value), parse_decimal(min)) { - if v <= m { - errors.push(ValidationError { - message: format!("value \"{value}\" in <{context}> must be greater than minExclusive {min}"), - line: None, column: None, - }); - } - } - } - Facet::MaxExclusive(max) => { - if let (Some(v), Some(m)) = (parse_decimal(value), parse_decimal(max)) { - if v >= m { - errors.push(ValidationError { - message: format!( - "value \"{value}\" in <{context}> must be less than maxExclusive {max}" - ), - line: None, - column: None, - }); - } - } - } - Facet::TotalDigits(total) => { - let digits = count_total_digits(value); - if digits > *total { - errors.push(ValidationError { - message: format!("value \"{value}\" in <{context}> has {digits} total digits but totalDigits is {total}"), - line: None, column: None, - }); - } - } - Facet::FractionDigits(frac) => { - let digits = count_fraction_digits(value); - if digits > *frac { - errors.push(ValidationError { - message: format!("value \"{value}\" in <{context}> has {digits} fraction digits but fractionDigits is {frac}"), - line: None, column: None, - }); - } - } - Facet::WhiteSpace(_) => {} - } -} - -/// Validates element attributes against the declared attribute list. -fn validate_attributes( - doc: &Document, - node: NodeId, - declared_attrs: &[XsdAttribute], - schema: &XsdSchema, - errors: &mut Vec<ValidationError>, -) { - let elem_name = doc.node_name(node).unwrap_or("<unknown>"); - let actual_attrs = doc.attributes(node); - for decl in declared_attrs { - let actual = actual_attrs.iter().find(|a| a.name == decl.name); - if decl.required && actual.is_none() { - errors.push(ValidationError { - message: format!( - "required attribute \"{}\" missing on element <{elem_name}>", - decl.name - ), - line: None, - column: None, - }); - continue; - } - if let Some(attr) = actual { - if let Some(ref fixed) = decl.fixed { - if attr.value != *fixed { - errors.push(ValidationError { - message: format!("attribute \"{}\" on <{elem_name}> must have fixed value \"{fixed}\", found \"{}\"", decl.name, attr.value), - line: None, column: None, - }); - } - } - let attr_context = format!("{elem_name}/@{}", decl.name); - if let Some(XsdType::Simple(st)) = schema.types.get(&decl.type_ref) { - validate_simple_value(&attr.value, st, &attr_context, schema, errors); - } else { - validate_builtin_value(&attr.value, &decl.type_ref, &attr_context, errors); - } - } - } -} - -// --------------------------------------------------------------------------- -// Pattern matching -// --------------------------------------------------------------------------- - -/// Simple XSD pattern matching using basic character class support. -fn matches_xsd_pattern(value: &str, pattern: &str) -> bool { - match_pattern_chars(value.as_bytes(), pattern.as_bytes(), 0, 0) -} - -/// Recursive pattern matcher. -fn match_pattern_chars(value: &[u8], pattern: &[u8], vi: usize, pi: usize) -> bool { - if pi >= pattern.len() { - return vi >= value.len(); - } - let (cc, next_pi) = parse_pattern_element(pattern, pi); - let has_star = next_pi < pattern.len() && pattern[next_pi] == b'*'; - let has_plus = next_pi < pattern.len() && pattern[next_pi] == b'+'; - let has_question = next_pi < pattern.len() && pattern[next_pi] == b'?'; - let aq = if has_star || has_plus || has_question { - next_pi + 1 - } else { - next_pi - }; - - if has_star { - match_quantified(value, pattern, vi, aq, &cc, 0) - } else if has_plus { - match_quantified(value, pattern, vi, aq, &cc, 1) - } else if has_question { - if match_pattern_chars(value, pattern, vi, aq) { - return true; - } - vi < value.len() - && matches_char_class(value[vi], &cc) - && match_pattern_chars(value, pattern, vi + 1, aq) - } else { - vi < value.len() - && matches_char_class(value[vi], &cc) - && match_pattern_chars(value, pattern, vi + 1, next_pi) - } -} - -/// Matches `min_count` or more occurrences of a character class. -fn match_quantified( - value: &[u8], - pattern: &[u8], - vi: usize, - aq: usize, - cc: &CharClass, - min_count: usize, -) -> bool { - let mut i = vi; - let mut count = 0; - // Try zero matches first (for star) - if min_count == 0 && match_pattern_chars(value, pattern, i, aq) { - return true; - } - while i < value.len() && matches_char_class(value[i], cc) { - i += 1; - count += 1; - if count >= min_count && match_pattern_chars(value, pattern, i, aq) { - return true; - } - } - false -} - -/// A character class element in a pattern. -enum CharClass { - Literal(u8), - Dot, - Digit, - Word, - CharSet(Vec<u8>), - NegCharSet(Vec<u8>), -} - -/// Parses one pattern element. -fn parse_pattern_element(pattern: &[u8], pi: usize) -> (CharClass, usize) { - if pi >= pattern.len() { - return (CharClass::Literal(0), pi); - } - match pattern[pi] { - b'.' => (CharClass::Dot, pi + 1), - b'\\' if pi + 1 < pattern.len() => match pattern[pi + 1] { - b'd' => (CharClass::Digit, pi + 2), - b'w' => (CharClass::Word, pi + 2), - ch => (CharClass::Literal(ch), pi + 2), - }, - b'[' => parse_char_set(pattern, pi), - ch => (CharClass::Literal(ch), pi + 1), - } -} - -/// Parses a character set `[...]` or `[^...]`. -fn parse_char_set(pattern: &[u8], pi: usize) -> (CharClass, usize) { - let negated = pi + 1 < pattern.len() && pattern[pi + 1] == b'^'; - let start = if negated { pi + 2 } else { pi + 1 }; - let mut end = start; - while end < pattern.len() && pattern[end] != b']' { - end += 1; - } - let chars = expand_char_ranges(&pattern[start..end]); - let class = if negated { - CharClass::NegCharSet(chars) - } else { - CharClass::CharSet(chars) - }; - (class, if end < pattern.len() { end + 1 } else { end }) -} - -/// Expands character ranges like `a-z`. -fn expand_char_ranges(set: &[u8]) -> Vec<u8> { - let mut result = Vec::new(); - let mut i = 0; - while i < set.len() { - if i + 2 < set.len() && set[i + 1] == b'-' { - for ch in set[i]..=set[i + 2] { - result.push(ch); - } - i += 3; - } else { - result.push(set[i]); - i += 1; - } - } - result -} - -/// Tests whether a byte matches a character class. -fn matches_char_class(byte: u8, class: &CharClass) -> bool { - match class { - CharClass::Literal(ch) => byte == *ch, - CharClass::Dot => true, - CharClass::Digit => byte.is_ascii_digit(), - CharClass::Word => byte.is_ascii_alphanumeric() || byte == b'_', - CharClass::CharSet(chars) => chars.contains(&byte), - CharClass::NegCharSet(chars) => !chars.contains(&byte), - } -} - -/// Counts total significant digits. -fn count_total_digits(value: &str) -> usize { - value - .trim() - .trim_start_matches('-') - .chars() - .filter(char::is_ascii_digit) - .count() -} - -/// Counts fractional digits after the decimal point. -fn count_fraction_digits(value: &str) -> usize { - value.find('.').map_or(0, |pos| { - value[pos + 1..] - .chars() - .filter(char::is_ascii_digit) - .count() - }) -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - - fn make_schema(xsd: &str) -> XsdSchema { - parse_xsd(xsd).unwrap() - } - - fn validate(xsd: &str, xml: &str) -> ValidationResult { - let schema = make_schema(xsd); - let doc = Document::parse_str(xml).unwrap(); - validate_xsd(&doc, &schema) - } - - #[test] - fn test_parse_simple_schema_with_one_element() { - let schema = make_schema( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="greeting" type="xs:string"/> - </xs:schema>"#, - ); - assert!(schema.elements.contains_key("greeting")); - assert_eq!( - schema.elements["greeting"].type_ref.as_deref(), - Some("string") - ); - } - - #[test] - fn test_parse_schema_with_complex_type_and_sequence() { - let schema = make_schema( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="person"><xs:complexType><xs:sequence> - <xs:element name="name" type="xs:string"/> - <xs:element name="age" type="xs:integer"/> - </xs:sequence></xs:complexType></xs:element> - </xs:schema>"#, - ); - let elem = &schema.elements["person"]; - if let Some(XsdType::Complex(ct)) = &elem.inline_type { - if let ComplexContent::Sequence(p) = &ct.content { - assert_eq!(p.len(), 2); - } else { - panic!("expected sequence"); - } - } else { - panic!("expected complex type"); - } - } - - #[test] - fn test_parse_schema_with_simple_type_restriction_enumeration() { - let schema = make_schema( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:simpleType name="colorType"><xs:restriction base="xs:string"> - <xs:enumeration value="red"/><xs:enumeration value="green"/><xs:enumeration value="blue"/> - </xs:restriction></xs:simpleType> - </xs:schema>"#, - ); - if let Some(XsdType::Simple(st)) = schema.types.get("colorType") { - if let SimpleTypeVariety::Restriction { facets, .. } = &st.variety { - assert!(facets.iter().any(|f| matches!(f, Facet::Enumeration(_)))); - } else { - panic!("expected restriction"); - } - } else { - panic!("expected simple type"); - } - } - - #[test] - fn test_parse_schema_with_attributes() { - let schema = make_schema( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="item"><xs:complexType><xs:sequence> - <xs:element name="name" type="xs:string"/> - </xs:sequence> - <xs:attribute name="id" type="xs:integer" use="required"/> - <xs:attribute name="category" type="xs:string"/> - </xs:complexType></xs:element> - </xs:schema>"#, - ); - if let Some(XsdType::Complex(ct)) = &schema.elements["item"].inline_type { - assert_eq!(ct.attributes.len(), 2); - assert!(ct.attributes[0].required); - assert!(!ct.attributes[1].required); - } else { - panic!("expected complex type"); - } - } - - #[test] - fn test_parse_schema_with_target_namespace() { - let schema = make_schema( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="http://example.com/ns"> - <xs:element name="root" type="xs:string"/> - </xs:schema>"#, - ); - assert_eq!( - schema.target_namespace.as_deref(), - Some("http://example.com/ns") - ); - } - - #[test] - fn test_parse_schema_with_nested_complex_types() { - let schema = make_schema( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="order"><xs:complexType><xs:sequence> - <xs:element name="item"><xs:complexType><xs:sequence> - <xs:element name="name" type="xs:string"/> - <xs:element name="qty" type="xs:integer"/> - </xs:sequence></xs:complexType></xs:element> - </xs:sequence></xs:complexType></xs:element> - </xs:schema>"#, - ); - if let Some(XsdType::Complex(ct)) = &schema.elements["order"].inline_type { - if let ComplexContent::Sequence(p) = &ct.content { - if let XsdParticle::Element(item) = &p[0] { - assert_eq!(item.name, "item"); - assert!(item.inline_type.is_some()); - } else { - panic!("expected element"); - } - } else { - panic!("expected sequence"); - } - } else { - panic!("expected complex type"); - } - } - - #[test] - fn test_validate_valid_document() { - let r = validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="greeting" type="xs:string"/> - </xs:schema>"#, - "<greeting>Hello World</greeting>", - ); - assert!(r.is_valid, "errors: {:?}", r.errors); - } - - #[test] - fn test_validate_invalid_missing_required_element() { - let r = validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="person"><xs:complexType><xs:sequence> - <xs:element name="name" type="xs:string"/> - <xs:element name="age" type="xs:integer"/> - </xs:sequence></xs:complexType></xs:element> - </xs:schema>"#, - "<person><name>Alice</name></person>", - ); - assert!(!r.is_valid); - assert!(r.errors.iter().any(|e| e.message.contains("age"))); - } - - #[test] - fn test_validate_invalid_wrong_order_sequence() { - let r = validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="person"><xs:complexType><xs:sequence> - <xs:element name="name" type="xs:string"/> - <xs:element name="age" type="xs:integer"/> - </xs:sequence></xs:complexType></xs:element> - </xs:schema>"#, - "<person><age>30</age><name>Alice</name></person>", - ); - assert!(!r.is_valid); - } - - #[test] - fn test_validate_invalid_too_many_occurrences() { - let r = validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="root"><xs:complexType><xs:sequence> - <xs:element name="item" type="xs:string" maxOccurs="2"/> - </xs:sequence></xs:complexType></xs:element> - </xs:schema>"#, - "<root><item>a</item><item>b</item><item>c</item></root>", - ); - assert!(!r.is_valid); - assert!( - r.errors.iter().any(|e| e.message.contains("item")), - "errors: {:?}", - r.errors - ); - } - - #[test] - fn test_validate_invalid_missing_required_attribute() { - let r = validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="item"><xs:complexType><xs:sequence> - <xs:element name="name" type="xs:string"/> - </xs:sequence> - <xs:attribute name="id" type="xs:integer" use="required"/> - </xs:complexType></xs:element> - </xs:schema>"#, - "<item><name>Test</name></item>", - ); - assert!(!r.is_valid); - assert!(r - .errors - .iter() - .any(|e| e.message.contains("required attribute"))); - } - - #[test] - fn test_validate_invalid_wrong_attribute_type() { - let r = validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="item"><xs:complexType><xs:sequence> - <xs:element name="name" type="xs:string"/> - </xs:sequence> - <xs:attribute name="count" type="xs:integer"/> - </xs:complexType></xs:element> - </xs:schema>"#, - r#"<item count="abc"><name>Test</name></item>"#, - ); - assert!(!r.is_valid); - assert!(r.errors.iter().any(|e| e.message.contains("integer"))); - } - - #[test] - fn test_validate_builtin_type_integer() { - assert!( - validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="c" type="xs:integer"/></xs:schema>"#, - "<c>42</c>" - ) - .is_valid - ); - assert!( - !validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="c" type="xs:integer"/></xs:schema>"#, - "<c>abc</c>" - ) - .is_valid - ); - } - - #[test] - fn test_validate_builtin_type_boolean() { - assert!( - validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="f" type="xs:boolean"/></xs:schema>"#, - "<f>true</f>" - ) - .is_valid - ); - assert!( - validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="f" type="xs:boolean"/></xs:schema>"#, - "<f>0</f>" - ) - .is_valid - ); - assert!( - !validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="f" type="xs:boolean"/></xs:schema>"#, - "<f>yes</f>" - ) - .is_valid - ); - } - - #[test] - fn test_validate_builtin_type_decimal() { - assert!( - validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="p" type="xs:decimal"/></xs:schema>"#, - "<p>19.99</p>" - ) - .is_valid - ); - assert!( - !validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="p" type="xs:decimal"/></xs:schema>"#, - "<p>abc</p>" - ) - .is_valid - ); - } - - #[test] - fn test_validate_string_facets_min_max_length() { - let xsd = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:simpleType name="nameType"><xs:restriction base="xs:string"> - <xs:minLength value="2"/><xs:maxLength value="10"/> - </xs:restriction></xs:simpleType> - <xs:element name="name" type="nameType"/> - </xs:schema>"#; - assert!(validate(xsd, "<name>Alice</name>").is_valid); - let short = validate(xsd, "<name>A</name>"); - assert!(!short.is_valid); - assert!(short.errors.iter().any(|e| e.message.contains("minLength"))); - let long = validate(xsd, "<name>Alexandrina Rose</name>"); - assert!(!long.is_valid); - assert!(long.errors.iter().any(|e| e.message.contains("maxLength"))); - } - - #[test] - fn test_validate_string_facets_pattern() { - let xsd = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:simpleType name="zipType"><xs:restriction base="xs:string"> - <xs:pattern value="\d\d\d\d\d"/> - </xs:restriction></xs:simpleType> - <xs:element name="zip" type="zipType"/> - </xs:schema>"#; - assert!(validate(xsd, "<zip>12345</zip>").is_valid); - assert!(!validate(xsd, "<zip>1234</zip>").is_valid); - assert!(!validate(xsd, "<zip>abcde</zip>").is_valid); - } - - #[test] - fn test_validate_numeric_facets_min_max_inclusive() { - let xsd = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:simpleType name="ageType"><xs:restriction base="xs:integer"> - <xs:minInclusive value="0"/><xs:maxInclusive value="150"/> - </xs:restriction></xs:simpleType> - <xs:element name="age" type="ageType"/> - </xs:schema>"#; - assert!(validate(xsd, "<age>25</age>").is_valid); - assert!(validate(xsd, "<age>0</age>").is_valid); - assert!(!validate(xsd, "<age>-1</age>").is_valid); - assert!(!validate(xsd, "<age>200</age>").is_valid); - } - - #[test] - fn test_validate_enumeration() { - let xsd = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:simpleType name="colorType"><xs:restriction base="xs:string"> - <xs:enumeration value="red"/><xs:enumeration value="green"/><xs:enumeration value="blue"/> - </xs:restriction></xs:simpleType> - <xs:element name="color" type="colorType"/> - </xs:schema>"#; - assert!(validate(xsd, "<color>red</color>").is_valid); - let r = validate(xsd, "<color>yellow</color>"); - assert!(!r.is_valid); - assert!(r.errors.iter().any(|e| e.message.contains("enumeration"))); - } - - #[test] - fn test_validate_mixed_content() { - let r = validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="para"><xs:complexType mixed="true"><xs:sequence> - <xs:element name="b" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> - </xs:sequence></xs:complexType></xs:element> - </xs:schema>"#, - "<para>Hello <b>world</b> end</para>", - ); - assert!(r.is_valid, "errors: {:?}", r.errors); - } - - #[test] - fn test_validate_choice_content_model() { - let xsd = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="pet"><xs:complexType><xs:choice> - <xs:element name="cat" type="xs:string"/> - <xs:element name="dog" type="xs:string"/> - </xs:choice></xs:complexType></xs:element> - </xs:schema>"#; - assert!(validate(xsd, "<pet><cat>Whiskers</cat></pet>").is_valid); - assert!(validate(xsd, "<pet><dog>Rex</dog></pet>").is_valid); - assert!(!validate(xsd, "<pet><fish>Nemo</fish></pet>").is_valid); - } - - #[test] - fn test_validate_optional_element() { - let xsd = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="person"><xs:complexType><xs:sequence> - <xs:element name="name" type="xs:string"/> - <xs:element name="email" type="xs:string" minOccurs="0"/> - </xs:sequence></xs:complexType></xs:element> - </xs:schema>"#; - assert!(validate(xsd, "<person><name>Alice</name><email>a@b</email></person>").is_valid); - let r = validate(xsd, "<person><name>Alice</name></person>"); - assert!(r.is_valid, "errors: {:?}", r.errors); - } - - #[test] - fn test_validate_unbounded_element() { - let r = validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="list"><xs:complexType><xs:sequence> - <xs:element name="item" type="xs:string" maxOccurs="unbounded"/> - </xs:sequence></xs:complexType></xs:element> - </xs:schema>"#, - "<list><item>a</item><item>b</item><item>c</item><item>d</item></list>", - ); - assert!(r.is_valid, "errors: {:?}", r.errors); - } - - #[test] - fn test_validate_undeclared_root_element() { - let r = validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="root" type="xs:string"/> - </xs:schema>"#, - "<unknown>text</unknown>", - ); - assert!(!r.is_valid); - assert!(r.errors.iter().any(|e| e.message.contains("not declared"))); - } - - #[test] - fn test_validate_empty_content_model() { - assert!( - validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="br"><xs:complexType/></xs:element> - </xs:schema>"#, - "<br/>" - ) - .is_valid - ); - assert!( - !validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="br"><xs:complexType/></xs:element> - </xs:schema>"#, - "<br>text</br>" - ) - .is_valid - ); - } - - #[test] - fn test_validate_fixed_attribute_value() { - assert!( - validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="item"><xs:complexType> - <xs:attribute name="version" type="xs:string" fixed="1.0"/> - </xs:complexType></xs:element> - </xs:schema>"#, - r#"<item version="1.0"/>"# - ) - .is_valid - ); - let r = validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="item"><xs:complexType> - <xs:attribute name="version" type="xs:string" fixed="1.0"/> - </xs:complexType></xs:element> - </xs:schema>"#, - r#"<item version="2.0"/>"#, - ); - assert!(!r.is_valid); - assert!(r.errors.iter().any(|e| e.message.contains("fixed"))); - } - - #[test] - fn test_validate_simple_content_extension() { - let r = validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:complexType name="priceType"><xs:simpleContent> - <xs:extension base="xs:decimal"> - <xs:attribute name="currency" type="xs:string" use="required"/> - </xs:extension> - </xs:simpleContent></xs:complexType> - <xs:element name="price" type="priceType"/> - </xs:schema>"#, - r#"<price currency="USD">19.99</price>"#, - ); - assert!(r.is_valid, "errors: {:?}", r.errors); - } - - #[test] - fn test_validate_date_types() { - assert!( - validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="d" type="xs:date"/></xs:schema>"#, - "<d>2024-01-15</d>" - ) - .is_valid - ); - assert!( - !validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="d" type="xs:date"/></xs:schema>"#, - "<d>not-a-date</d>" - ) - .is_valid - ); - assert!( - validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="dt" type="xs:dateTime"/></xs:schema>"#, - "<dt>2024-01-15T10:30:00</dt>" - ) - .is_valid - ); - assert!( - validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="t" type="xs:time"/></xs:schema>"#, - "<t>10:30:00</t>" - ) - .is_valid - ); - } - - #[test] - fn test_validate_all_content_model() { - let xsd = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="config"><xs:complexType><xs:all> - <xs:element name="host" type="xs:string"/> - <xs:element name="port" type="xs:integer"/> - </xs:all></xs:complexType></xs:element> - </xs:schema>"#; - assert!( - validate( - xsd, - "<config><host>localhost</host><port>8080</port></config>" - ) - .is_valid - ); - assert!( - validate( - xsd, - "<config><port>8080</port><host>localhost</host></config>" - ) - .is_valid - ); - } - - #[test] - fn test_parse_xsd_invalid_xml() { - assert!(parse_xsd("<not valid xml<<<").is_err()); - } - - #[test] - fn test_parse_xsd_wrong_root_element() { - assert!( - parse_xsd(r#"<xs:element xmlns:xs="http://www.w3.org/2001/XMLSchema" name="x"/>"#) - .is_err() - ); - } - - #[test] - fn test_validate_named_complex_type() { - let r = validate( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:complexType name="addressType"><xs:sequence> - <xs:element name="street" type="xs:string"/> - <xs:element name="city" type="xs:string"/> - </xs:sequence></xs:complexType> - <xs:element name="address" type="addressType"/> - </xs:schema>"#, - "<address><street>123 Main St</street><city>Springfield</city></address>", - ); - assert!(r.is_valid, "errors: {:?}", r.errors); - } - - #[test] - fn test_whitespace_preserve() { - use super::apply_whitespace_normalization; - use super::WhiteSpaceValue; - let result = apply_whitespace_normalization(" hello\tworld\n", &WhiteSpaceValue::Preserve); - assert_eq!(result, " hello\tworld\n"); - } - - #[test] - fn test_whitespace_replace() { - use super::apply_whitespace_normalization; - use super::WhiteSpaceValue; - let result = apply_whitespace_normalization("a\tb\nc\r", &WhiteSpaceValue::Replace); - assert_eq!(result, "a b c "); - } - - #[test] - fn test_whitespace_collapse() { - use super::apply_whitespace_normalization; - use super::WhiteSpaceValue; - let result = - apply_whitespace_normalization(" hello \t world \n ", &WhiteSpaceValue::Collapse); - assert_eq!(result, "hello world"); - } - - // ----------------------------------------------------------------------- - // Phase 0: Prefix map and QName resolution infrastructure - // ----------------------------------------------------------------------- - - #[test] - fn test_build_prefix_map() { - let doc = Document::parse_str( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - xmlns:tns="http://example.com/types" - targetNamespace="http://example.com/types"> - <xs:element name="root" type="xs:string"/> - </xs:schema>"#, - ) - .unwrap(); - let root = doc.root_element().unwrap(); - let map = build_prefix_map(&doc, root); - assert_eq!( - map.get("xs"), - Some(&"http://www.w3.org/2001/XMLSchema".to_string()) - ); - assert_eq!( - map.get("tns"), - Some(&"http://example.com/types".to_string()) - ); - } - - #[test] - fn test_resolve_type_qname_builtin() { - let mut map = HashMap::new(); - map.insert( - "xs".to_string(), - "http://www.w3.org/2001/XMLSchema".to_string(), - ); - let (ns, local) = resolve_type_qname("xs:string", &map); - assert_eq!(ns.as_deref(), Some("http://www.w3.org/2001/XMLSchema")); - assert_eq!(local, "string"); - } - - #[test] - fn test_resolve_type_qname_local() { - let mut map = HashMap::new(); - map.insert("tns".to_string(), "http://example.com/types".to_string()); - let (ns, local) = resolve_type_qname("tns:MyType", &map); - assert_eq!(ns.as_deref(), Some("http://example.com/types")); - assert_eq!(local, "MyType"); - } - - #[test] - fn test_resolve_type_qname_unprefixed() { - let map = HashMap::new(); - let (ns, local) = resolve_type_qname("MyType", &map); - assert_eq!(ns, None); - assert_eq!(local, "MyType"); - } - - // ----------------------------------------------------------------------- - // Phase 1: xsd:include tests - // ----------------------------------------------------------------------- - - fn make_resolver(schemas: Vec<(&str, &str)>) -> impl SchemaResolver { - let map: HashMap<String, String> = schemas - .into_iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - move |location: &str, _base: Option<&str>| map.get(location).cloned() - } - - #[test] - fn test_include_ignored_without_resolver() { - // Without a resolver, include is silently skipped (backward compat) - let schema = parse_xsd( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:include schemaLocation="types.xsd"/> - <xs:element name="root" type="xs:string"/> - </xs:schema>"#, - ) - .unwrap(); - assert!(schema.elements.contains_key("root")); - } - - #[test] - fn test_include_merges_types() { - let resolver = make_resolver(vec![( - "types.xsd", - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:complexType name="PersonType"><xs:sequence> - <xs:element name="name" type="xs:string"/> - </xs:sequence></xs:complexType> - </xs:schema>"#, - )]); - let opts = XsdParseOptions { - resolver: Some(&resolver), - base_uri: None, - }; - let schema = parse_xsd_with_options( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:include schemaLocation="types.xsd"/> - <xs:element name="person" type="PersonType"/> - </xs:schema>"#, - &opts, - ) - .unwrap(); - assert!(schema.types.contains_key("PersonType")); - assert!(schema.elements.contains_key("person")); - } - - #[test] - fn test_include_merges_elements() { - let resolver = make_resolver(vec![( - "elements.xsd", - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="greeting" type="xs:string"/> - </xs:schema>"#, - )]); - let opts = XsdParseOptions { - resolver: Some(&resolver), - base_uri: None, - }; - let schema = parse_xsd_with_options( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:include schemaLocation="elements.xsd"/> - <xs:element name="root" type="xs:string"/> - </xs:schema>"#, - &opts, - ) - .unwrap(); - assert!(schema.elements.contains_key("greeting")); - assert!(schema.elements.contains_key("root")); - } - - #[test] - fn test_include_chameleon() { - // Included schema has no targetNamespace — adopts includer's namespace - let resolver = make_resolver(vec![( - "types.xsd", - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:complexType name="AddrType"><xs:sequence> - <xs:element name="street" type="xs:string"/> - </xs:sequence></xs:complexType> - </xs:schema>"#, - )]); - let opts = XsdParseOptions { - resolver: Some(&resolver), - base_uri: None, - }; - let schema = parse_xsd_with_options( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - targetNamespace="http://example.com/main"> - <xs:include schemaLocation="types.xsd"/> - <xs:element name="addr" type="AddrType"/> - </xs:schema>"#, - &opts, - ) - .unwrap(); - // The type should be merged into the main schema - assert!(schema.types.contains_key("AddrType")); - } - - #[test] - fn test_include_namespace_mismatch_error() { - let resolver = make_resolver(vec![( - "other.xsd", - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - targetNamespace="http://other.com"> - <xs:element name="x" type="xs:string"/> - </xs:schema>"#, - )]); - let opts = XsdParseOptions { - resolver: Some(&resolver), - base_uri: None, - }; - let result = parse_xsd_with_options( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - targetNamespace="http://example.com"> - <xs:include schemaLocation="other.xsd"/> - </xs:schema>"#, - &opts, - ); - assert!(result.is_err()); - assert!(result.unwrap_err().message.contains("namespace")); - } - - #[test] - fn test_include_cycle_detection() { - // A includes B, B includes A — should not loop - let resolver = make_resolver(vec![ - ( - "a.xsd", - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:include schemaLocation="b.xsd"/> - <xs:element name="a" type="xs:string"/> - </xs:schema>"#, - ), - ( - "b.xsd", - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:include schemaLocation="a.xsd"/> - <xs:element name="b" type="xs:string"/> - </xs:schema>"#, - ), - ]); - let opts = XsdParseOptions { - resolver: Some(&resolver), - base_uri: None, - }; - // Parse from a.xsd content — should include b.xsd but not re-include a.xsd - let schema = parse_xsd_with_options( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:include schemaLocation="a.xsd"/> - <xs:element name="root" type="xs:string"/> - </xs:schema>"#, - &opts, - ) - .unwrap(); - assert!(schema.elements.contains_key("root")); - assert!(schema.elements.contains_key("a")); - assert!(schema.elements.contains_key("b")); - } - - #[test] - fn test_include_transitive() { - // A includes B, B includes C — declarations from C available in A - let resolver = make_resolver(vec![ - ( - "b.xsd", - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:include schemaLocation="c.xsd"/> - <xs:element name="b" type="xs:string"/> - </xs:schema>"#, - ), - ( - "c.xsd", - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:complexType name="CType"><xs:sequence> - <xs:element name="val" type="xs:string"/> - </xs:sequence></xs:complexType> - </xs:schema>"#, - ), - ]); - let opts = XsdParseOptions { - resolver: Some(&resolver), - base_uri: None, - }; - let schema = parse_xsd_with_options( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:include schemaLocation="b.xsd"/> - <xs:element name="root" type="CType"/> - </xs:schema>"#, - &opts, - ) - .unwrap(); - assert!(schema.elements.contains_key("root")); - assert!(schema.elements.contains_key("b")); - assert!(schema.types.contains_key("CType")); - } - - #[test] - fn test_include_resolver_returns_none() { - let resolver = make_resolver(vec![]); - let opts = XsdParseOptions { - resolver: Some(&resolver), - base_uri: None, - }; - let result = parse_xsd_with_options( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:include schemaLocation="nonexistent.xsd"/> - </xs:schema>"#, - &opts, - ); - assert!(result.is_err()); - assert!(result.unwrap_err().message.contains("nonexistent.xsd")); - } - - // ----------------------------------------------------------------------- - // Phase 2: xsd:import tests - // ----------------------------------------------------------------------- - - #[test] - fn test_import_cross_namespace_type() { - let resolver = make_resolver(vec![( - "types.xsd", - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - targetNamespace="http://example.com/types"> - <xs:complexType name="AddressType"><xs:sequence> - <xs:element name="street" type="xs:string"/> - <xs:element name="city" type="xs:string"/> - </xs:sequence></xs:complexType> - </xs:schema>"#, - )]); - let opts = XsdParseOptions { - resolver: Some(&resolver), - base_uri: None, - }; - let schema = parse_xsd_with_options( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - xmlns:tns="http://example.com/types" - targetNamespace="http://example.com/main"> - <xs:import namespace="http://example.com/types" schemaLocation="types.xsd"/> - <xs:element name="address" type="tns:AddressType"/> - </xs:schema>"#, - &opts, - ) - .unwrap(); - // The imported type should be resolvable - assert!(schema - .imported_namespaces - .contains_key("http://example.com/types")); - let imported = &schema.imported_namespaces["http://example.com/types"]; - assert!(imported.types.contains_key("AddressType")); - } - - #[test] - fn test_import_namespace_mismatch_error() { - let resolver = make_resolver(vec![( - "types.xsd", - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - targetNamespace="http://wrong.com"> - <xs:element name="x" type="xs:string"/> - </xs:schema>"#, - )]); - let opts = XsdParseOptions { - resolver: Some(&resolver), - base_uri: None, - }; - let result = parse_xsd_with_options( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:import namespace="http://expected.com" schemaLocation="types.xsd"/> - </xs:schema>"#, - &opts, - ); - assert!(result.is_err()); - assert!(result.unwrap_err().message.contains("namespace")); - } - - #[test] - fn test_import_without_schema_location() { - // Import with just namespace attribute is valid (declares expected ns) - let schema = parse_xsd( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:import namespace="http://example.com/types"/> - <xs:element name="root" type="xs:string"/> - </xs:schema>"#, - ) - .unwrap(); - assert!(schema.elements.contains_key("root")); - } - - #[test] - fn test_import_cycle_detection() { - let resolver = make_resolver(vec![ - ( - "a.xsd", - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - targetNamespace="http://example.com/a"> - <xs:import namespace="http://example.com/b" schemaLocation="b.xsd"/> - <xs:element name="a" type="xs:string"/> - </xs:schema>"#, - ), - ( - "b.xsd", - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - targetNamespace="http://example.com/b"> - <xs:import namespace="http://example.com/a" schemaLocation="a.xsd"/> - <xs:element name="b" type="xs:string"/> - </xs:schema>"#, - ), - ]); - let opts = XsdParseOptions { - resolver: Some(&resolver), - base_uri: None, - }; - let schema = parse_xsd_with_options( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - targetNamespace="http://example.com/main"> - <xs:import namespace="http://example.com/a" schemaLocation="a.xsd"/> - <xs:element name="root" type="xs:string"/> - </xs:schema>"#, - &opts, - ) - .unwrap(); - assert!(schema.elements.contains_key("root")); - assert!(schema - .imported_namespaces - .contains_key("http://example.com/a")); - } - - #[test] - fn test_import_multiple_namespaces() { - let resolver = make_resolver(vec![ - ( - "types.xsd", - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - targetNamespace="http://example.com/types"> - <xs:complexType name="NameType"><xs:sequence> - <xs:element name="first" type="xs:string"/> - </xs:sequence></xs:complexType> - </xs:schema>"#, - ), - ( - "addr.xsd", - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - targetNamespace="http://example.com/addr"> - <xs:complexType name="AddrType"><xs:sequence> - <xs:element name="city" type="xs:string"/> - </xs:sequence></xs:complexType> - </xs:schema>"#, - ), - ]); - let opts = XsdParseOptions { - resolver: Some(&resolver), - base_uri: None, - }; - let schema = parse_xsd_with_options( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - xmlns:t="http://example.com/types" - xmlns:a="http://example.com/addr"> - <xs:import namespace="http://example.com/types" schemaLocation="types.xsd"/> - <xs:import namespace="http://example.com/addr" schemaLocation="addr.xsd"/> - <xs:element name="root" type="xs:string"/> - </xs:schema>"#, - &opts, - ) - .unwrap(); - assert!(schema - .imported_namespaces - .contains_key("http://example.com/types")); - assert!(schema - .imported_namespaces - .contains_key("http://example.com/addr")); - assert!(schema.imported_namespaces["http://example.com/types"] - .types - .contains_key("NameType")); - assert!(schema.imported_namespaces["http://example.com/addr"] - .types - .contains_key("AddrType")); - } - - #[test] - fn test_import_and_include_combined() { - let resolver = make_resolver(vec![ - ( - "local_types.xsd", - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:complexType name="LocalType"><xs:sequence> - <xs:element name="value" type="xs:string"/> - </xs:sequence></xs:complexType> - </xs:schema>"#, - ), - ( - "foreign.xsd", - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - targetNamespace="http://foreign.com"> - <xs:complexType name="ForeignType"><xs:sequence> - <xs:element name="data" type="xs:string"/> - </xs:sequence></xs:complexType> - </xs:schema>"#, - ), - ]); - let opts = XsdParseOptions { - resolver: Some(&resolver), - base_uri: None, - }; - let schema = parse_xsd_with_options( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - xmlns:f="http://foreign.com"> - <xs:include schemaLocation="local_types.xsd"/> - <xs:import namespace="http://foreign.com" schemaLocation="foreign.xsd"/> - <xs:element name="root" type="LocalType"/> - </xs:schema>"#, - &opts, - ) - .unwrap(); - assert!(schema.types.contains_key("LocalType")); - assert!(schema - .imported_namespaces - .contains_key("http://foreign.com")); - assert!(schema.imported_namespaces["http://foreign.com"] - .types - .contains_key("ForeignType")); - } - - // ----------------------------------------------------------------------- - // Phase 3: Namespace-aware validation tests - // ----------------------------------------------------------------------- - - #[test] - fn test_validate_with_imported_types() { - // End-to-end: parse multi-schema, validate document - let resolver = make_resolver(vec![( - "types.xsd", - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - targetNamespace="http://example.com/types"> - <xs:complexType name="AddressType"><xs:sequence> - <xs:element name="street" type="xs:string"/> - <xs:element name="city" type="xs:string"/> - </xs:sequence></xs:complexType> - </xs:schema>"#, - )]); - let opts = XsdParseOptions { - resolver: Some(&resolver), - base_uri: None, - }; - let schema = parse_xsd_with_options( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - xmlns:tns="http://example.com/types"> - <xs:import namespace="http://example.com/types" schemaLocation="types.xsd"/> - <xs:element name="address" type="tns:AddressType"/> - </xs:schema>"#, - &opts, - ) - .unwrap(); - - let doc = Document::parse_str( - "<address><street>123 Main</street><city>Springfield</city></address>", - ) - .unwrap(); - let result = validate_xsd(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - } - - #[test] - fn test_validate_imported_content_model() { - // Validate that child elements typed from imported schemas validate - let resolver = make_resolver(vec![( - "types.xsd", - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - targetNamespace="http://example.com/types"> - <xs:complexType name="NameType"><xs:sequence> - <xs:element name="first" type="xs:string"/> - <xs:element name="last" type="xs:string"/> - </xs:sequence></xs:complexType> - </xs:schema>"#, - )]); - let opts = XsdParseOptions { - resolver: Some(&resolver), - base_uri: None, - }; - let schema = parse_xsd_with_options( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - xmlns:t="http://example.com/types"> - <xs:import namespace="http://example.com/types" schemaLocation="types.xsd"/> - <xs:element name="person"><xs:complexType><xs:sequence> - <xs:element name="name" type="t:NameType"/> - <xs:element name="age" type="xs:integer"/> - </xs:sequence></xs:complexType></xs:element> - </xs:schema>"#, - &opts, - ) - .unwrap(); - - // Valid document - let doc = Document::parse_str( - "<person><name><first>John</first><last>Doe</last></name><age>30</age></person>", - ) - .unwrap(); - let result = validate_xsd(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - - // Invalid document: wrong child element in imported type - let doc = Document::parse_str( - "<person><name><wrong>X</wrong><last>Doe</last></name><age>30</age></person>", - ) - .unwrap(); - let result = validate_xsd(&doc, &schema); - assert!(!result.is_valid); - } - - #[test] - fn test_validate_included_type_validation() { - // Validate that included types work in validation too - let resolver = make_resolver(vec![( - "types.xsd", - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:complexType name="ItemType"><xs:sequence> - <xs:element name="name" type="xs:string"/> - <xs:element name="qty" type="xs:integer"/> - </xs:sequence></xs:complexType> - </xs:schema>"#, - )]); - let opts = XsdParseOptions { - resolver: Some(&resolver), - base_uri: None, - }; - let schema = parse_xsd_with_options( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:include schemaLocation="types.xsd"/> - <xs:element name="item" type="ItemType"/> - </xs:schema>"#, - &opts, - ) - .unwrap(); - - let doc = Document::parse_str("<item><name>Widget</name><qty>5</qty></item>").unwrap(); - let result = validate_xsd(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - } - - // ----------------------------------------------------------------------- - // Element ref support tests - // ----------------------------------------------------------------------- - - #[test] - fn test_element_ref_local() { - // ref to a global element in the same schema - let schema = parse_xsd( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="name" type="xs:string"/> - <xs:element name="person"> - <xs:complexType><xs:sequence> - <xs:element ref="name"/> - </xs:sequence></xs:complexType> - </xs:element> - </xs:schema>"#, - ) - .unwrap(); - - let doc = Document::parse_str("<person><name>Alice</name></person>").unwrap(); - let result = validate_xsd(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - } - - #[test] - fn test_element_ref_imported() { - // ref to a global element in an imported namespace (UBL pattern) - let resolver = make_resolver(vec![( - "components.xsd", - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - targetNamespace="http://example.com/components"> - <xs:element name="ID" type="xs:string"/> - <xs:element name="Name" type="xs:string"/> - </xs:schema>"#, - )]); - let opts = XsdParseOptions { - resolver: Some(&resolver), - base_uri: None, - }; - let schema = parse_xsd_with_options( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - xmlns:cbc="http://example.com/components"> - <xs:import namespace="http://example.com/components" - schemaLocation="components.xsd"/> - <xs:element name="Order"> - <xs:complexType><xs:sequence> - <xs:element ref="cbc:ID"/> - <xs:element ref="cbc:Name" minOccurs="0"/> - </xs:sequence></xs:complexType> - </xs:element> - </xs:schema>"#, - &opts, - ) - .unwrap(); - - let doc = Document::parse_str("<Order><ID>ORD-1</ID><Name>Test</Name></Order>").unwrap(); - let result = validate_xsd(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - - // Valid without optional Name - let doc2 = Document::parse_str("<Order><ID>ORD-2</ID></Order>").unwrap(); - let result2 = validate_xsd(&doc2, &schema); - assert!(result2.is_valid, "errors: {:?}", result2.errors); - - // Invalid: wrong element - let doc3 = Document::parse_str("<Order><Wrong>X</Wrong></Order>").unwrap(); - let result3 = validate_xsd(&doc3, &schema); - assert!(!result3.is_valid); - } - - #[test] - fn test_element_ref_with_occurs() { - // ref with minOccurs/maxOccurs overrides - let schema = parse_xsd( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="item" type="xs:string"/> - <xs:element name="list"> - <xs:complexType><xs:sequence> - <xs:element ref="item" minOccurs="1" maxOccurs="unbounded"/> - </xs:sequence></xs:complexType> - </xs:element> - </xs:schema>"#, - ) - .unwrap(); - - let doc = Document::parse_str("<list><item>a</item><item>b</item></list>").unwrap(); - let result = validate_xsd(&doc, &schema); - assert!(result.is_valid, "errors: {:?}", result.errors); - - // Invalid: empty list (minOccurs=1) - let doc2 = Document::parse_str("<list/>").unwrap(); - let result2 = validate_xsd(&doc2, &schema); - assert!(!result2.is_valid); - } - - #[test] - fn test_element_form_default_qualified() { - let schema = parse_xsd( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - targetNamespace="urn:example" - xmlns:tns="urn:example" - elementFormDefault="qualified"> - <xs:element name="order"> - <xs:complexType><xs:sequence> - <xs:element name="item" type="xs:string"/> - </xs:sequence></xs:complexType> - </xs:element> - </xs:schema>"#, - ) - .unwrap(); - assert_eq!(schema.element_form_default, FormDefault::Qualified); - - // Valid: child element is namespace-qualified - let doc = Document::parse_str(r#"<order xmlns="urn:example"><item>Widget</item></order>"#) - .unwrap(); - let result = validate_xsd(&doc, &schema); - assert!( - result.is_valid, - "qualified children should pass: {:?}", - result.errors - ); - - // Invalid: child element is NOT namespace-qualified - let doc_fail = Document::parse_str( - r#"<tns:order xmlns:tns="urn:example"><item>Widget</item></tns:order>"#, - ) - .unwrap(); - let result = validate_xsd(&doc_fail, &schema); - assert!( - !result.is_valid, - "unqualified child should fail when elementFormDefault=qualified" - ); - } - - #[test] - fn test_element_form_default_unqualified() { - let schema = parse_xsd( - r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" - targetNamespace="urn:example" - xmlns:tns="urn:example"> - <xs:element name="order"> - <xs:complexType><xs:sequence> - <xs:element name="item" type="xs:string"/> - </xs:sequence></xs:complexType> - </xs:element> - </xs:schema>"#, - ) - .unwrap(); - assert_eq!(schema.element_form_default, FormDefault::Unqualified); - - // Valid: child element without namespace (unqualified is default) - let doc = Document::parse_str( - r#"<tns:order xmlns:tns="urn:example"><item>Widget</item></tns:order>"#, - ) - .unwrap(); - let result = validate_xsd(&doc, &schema); - assert!( - result.is_valid, - "unqualified children should pass: {:?}", - result.errors - ); - } -} diff --git a/browser/vendor/xmloxide/src/xinclude/mod.rs b/browser/vendor/xmloxide/src/xinclude/mod.rs deleted file mode 100644 index a2e45c024..000000000 --- a/browser/vendor/xmloxide/src/xinclude/mod.rs +++ /dev/null @@ -1,853 +0,0 @@ -//! `XInclude` 1.0 processing. -//! -//! This module implements the [XML Inclusions (XInclude) 1.0](https://www.w3.org/TR/xinclude/) -//! specification. `XInclude` allows XML documents to reference and include content from -//! other XML or text resources using `xi:include` elements. -//! -//! # Overview -//! -//! `XInclude` processing replaces `<xi:include>` elements (in the -//! `http://www.w3.org/2001/XInclude` namespace) with the content they reference. -//! The `href` attribute specifies the URI of the resource to include, and the -//! `parse` attribute determines whether the content is included as parsed XML -//! (`parse="xml"`, the default) or as a text node (`parse="text"`). -//! -//! If a resource cannot be resolved, the processor looks for an `<xi:fallback>` -//! child element and uses its content instead. If no fallback is provided, the -//! include is recorded as an error. -//! -//! # Design -//! -//! Since the core library does not perform I/O, the caller provides a resolver -//! callback (`Fn(&str) -> Option<String>`) that maps URIs to content. This -//! allows the library to be used in any environment (filesystem, network, -//! in-memory test fixtures, etc.). - -use std::collections::HashSet; -use std::fmt; - -use crate::tree::{Document, NodeId, NodeKind}; - -/// The `XInclude` namespace URI. -/// -/// All `xi:include` and `xi:fallback` elements must be in this namespace -/// for `XInclude` processing to recognize them. -pub const XINCLUDE_NS: &str = "http://www.w3.org/2001/XInclude"; - -/// The local name of the include element. -const INCLUDE_ELEMENT: &str = "include"; - -/// The local name of the fallback element. -const FALLBACK_ELEMENT: &str = "fallback"; - -/// Options for `XInclude` processing. -/// -/// Controls the behavior of [`process_xincludes`], such as the maximum -/// nesting depth for recursive includes. -/// -/// # Examples -/// -/// ``` -/// use xmloxide::xinclude::XIncludeOptions; -/// -/// let opts = XIncludeOptions::default(); -/// assert_eq!(opts.max_depth, 50); -/// ``` -#[derive(Debug, Clone)] -pub struct XIncludeOptions { - /// Maximum nesting depth for recursive includes. - /// - /// When an included document itself contains `xi:include` elements, - /// processing recurses. This limit prevents infinite recursion or - /// excessively deep include chains. The default is 50. - pub max_depth: usize, -} - -impl Default for XIncludeOptions { - fn default() -> Self { - Self { max_depth: 50 } - } -} - -/// An error encountered during `XInclude` processing. -/// -/// Errors are collected rather than stopping processing, so that as many -/// includes as possible are resolved even when some fail. -#[derive(Debug, Clone)] -pub struct XIncludeError { - /// Human-readable description of the error. - pub message: String, - /// The `href` that caused the error, if applicable. - pub href: Option<String>, -} - -impl fmt::Display for XIncludeError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match &self.href { - Some(href) => write!(f, "XInclude error for '{href}': {}", self.message), - None => write!(f, "XInclude error: {}", self.message), - } - } -} - -/// Result of `XInclude` processing. -/// -/// Contains the processed document with all resolvable includes expanded, -/// along with statistics and any errors encountered. -pub struct XIncludeResult { - /// Number of includes that were successfully processed. - pub inclusions: usize, - /// Errors encountered during processing. - /// - /// Each error corresponds to an `xi:include` element that could not - /// be resolved and had no usable `xi:fallback`. - pub errors: Vec<XIncludeError>, -} - -/// Processes `XInclude` elements in a document. -/// -/// Walks the document tree looking for elements in the `XInclude` namespace -/// (`http://www.w3.org/2001/XInclude`) with local name `include`. For each -/// such element: -/// -/// 1. The `href` attribute is read to determine the resource URI. -/// 2. The `parse` attribute is read to determine how to interpret the content -/// (`"xml"` or `"text"`, defaulting to `"xml"`). -/// 3. The `resolver` callback is called with the href (minus any fragment) to -/// obtain the resource content. -/// 4. On success, the `xi:include` element is replaced with the included content. -/// 5. On failure, the `xi:fallback` child is used if present; otherwise an error -/// is recorded. -/// -/// The resolver callback receives the href string and returns `Some(content)` if -/// the resource is available, or `None` if it cannot be resolved. -/// -/// # Circular inclusion detection -/// -/// The processor tracks which hrefs have been included in the current inclusion -/// chain and rejects any attempt to include an already-active href, preventing -/// infinite loops. -/// -/// # Examples -/// -/// ``` -/// use xmloxide::Document; -/// use xmloxide::xinclude::{process_xincludes, XIncludeOptions}; -/// -/// let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"> -/// <xi:include href="greeting.xml"/> -/// </doc>"#; -/// -/// let mut doc = Document::parse_str(xml).unwrap(); -/// let result = process_xincludes(&mut doc, |href| { -/// match href { -/// "greeting.xml" => Some("<hello>world</hello>".to_string()), -/// _ => None, -/// } -/// }, &XIncludeOptions::default()); -/// -/// assert_eq!(result.inclusions, 1); -/// assert!(result.errors.is_empty()); -/// ``` -pub fn process_xincludes<F>( - doc: &mut Document, - resolver: F, - options: &XIncludeOptions, -) -> XIncludeResult -where - F: Fn(&str) -> Option<String>, -{ - let mut state = ProcessingState { - inclusions: 0, - errors: Vec::new(), - active_hrefs: HashSet::new(), - max_depth: options.max_depth, - }; - - process_node(doc, doc.root(), &resolver, &mut state, 0); - - XIncludeResult { - inclusions: state.inclusions, - errors: state.errors, - } -} - -/// Internal mutable state carried through the `XInclude` processing pass. -struct ProcessingState { - /// Number of successfully processed includes. - inclusions: usize, - /// Accumulated errors. - errors: Vec<XIncludeError>, - /// Set of hrefs currently in the inclusion chain (for cycle detection). - active_hrefs: HashSet<String>, - /// Maximum allowed nesting depth. - max_depth: usize, -} - -/// Recursively processes `XInclude` elements under the given node. -/// -/// We collect the list of children first (as a `Vec<NodeId>`) to avoid -/// borrowing issues while mutating the document. -fn process_node<F>( - doc: &mut Document, - node: NodeId, - resolver: &F, - state: &mut ProcessingState, - depth: usize, -) where - F: Fn(&str) -> Option<String>, -{ - // Collect children before iteration, since we may mutate the tree. - let children: Vec<NodeId> = doc.children(node).collect(); - - for child in children { - if is_xinclude_element(doc, child) { - process_include_element(doc, child, resolver, state, depth); - } else { - // Recurse into non-include elements to find nested xi:include. - process_node(doc, child, resolver, state, depth); - } - } -} - -/// Checks whether a node is an `xi:include` element in the `XInclude` namespace. -fn is_xinclude_element(doc: &Document, node: NodeId) -> bool { - if let NodeKind::Element { - name, namespace, .. - } = &doc.node(node).kind - { - name == INCLUDE_ELEMENT && namespace.as_deref() == Some(XINCLUDE_NS) - } else { - false - } -} - -/// Checks whether a node is an `xi:fallback` element in the `XInclude` namespace. -fn is_fallback_element(doc: &Document, node: NodeId) -> bool { - if let NodeKind::Element { - name, namespace, .. - } = &doc.node(node).kind - { - name == FALLBACK_ELEMENT && namespace.as_deref() == Some(XINCLUDE_NS) - } else { - false - } -} - -/// Processes a single `xi:include` element. -/// -/// Reads the `href` and `parse` attributes, resolves the content via the -/// resolver, and replaces the `xi:include` element with the result. -fn process_include_element<F>( - doc: &mut Document, - include_node: NodeId, - resolver: &F, - state: &mut ProcessingState, - depth: usize, -) where - F: Fn(&str) -> Option<String>, -{ - // Read attributes from the xi:include element. - let href = doc.attribute(include_node, "href").map(str::to_owned); - let parse = doc - .attribute(include_node, "parse") - .unwrap_or("xml") - .to_owned(); - - // Validate: href is required. - let Some(href) = href else { - state.errors.push(XIncludeError { - message: "xi:include element is missing required 'href' attribute".to_string(), - href: None, - }); - // Remove the xi:include element. - doc.detach(include_node); - return; - }; - - // Validate: parse must be "xml" or "text". - if parse != "xml" && parse != "text" { - state.errors.push(XIncludeError { - message: format!("invalid parse attribute value '{parse}'; expected 'xml' or 'text'"), - href: Some(href), - }); - doc.detach(include_node); - return; - } - - // Check depth limit. - if depth >= state.max_depth { - state.errors.push(XIncludeError { - message: format!( - "maximum XInclude nesting depth ({}) exceeded", - state.max_depth - ), - href: Some(href), - }); - doc.detach(include_node); - return; - } - - // Strip fragment identifier for resolution (but keep it for potential - // XPointer processing later). - let (base_href, _fragment) = split_fragment(&href); - - // Check for circular inclusion. - if state.active_hrefs.contains(base_href) { - state.errors.push(XIncludeError { - message: "circular inclusion detected".to_string(), - href: Some(href), - }); - doc.detach(include_node); - return; - } - - // Resolve the resource. - let content = resolver(base_href); - - match content { - Some(content) => { - // Mark this href as active in the inclusion chain. - state.active_hrefs.insert(base_href.to_owned()); - - let success = match parse.as_str() { - "xml" => process_xml_include(doc, include_node, &content, resolver, state, depth), - "text" => process_text_include(doc, include_node, &content), - _ => false, // Already validated above. - }; - - // Remove from active set after processing. - state.active_hrefs.remove(base_href); - - if success { - state.inclusions += 1; - } - } - None => { - // Resource not found — try fallback. - if !try_fallback(doc, include_node, resolver, state, depth) { - state.errors.push(XIncludeError { - message: "resource not found and no xi:fallback provided".to_string(), - href: Some(href), - }); - doc.detach(include_node); - } - } - } -} - -/// Processes an XML include: parses the content as XML and replaces the -/// `xi:include` element with the parsed children. -/// -/// Returns `true` on success. -fn process_xml_include<F>( - doc: &mut Document, - include_node: NodeId, - content: &str, - resolver: &F, - state: &mut ProcessingState, - depth: usize, -) -> bool -where - F: Fn(&str) -> Option<String>, -{ - // Parse the included content as an XML document. - let included_doc = match Document::parse_str(content) { - Ok(d) => d, - Err(e) => { - // Parse failure — try fallback, otherwise record error. - if try_fallback(doc, include_node, resolver, state, depth) { - return false; - } - state.errors.push(XIncludeError { - message: format!("failed to parse included XML: {e}"), - href: None, - }); - doc.detach(include_node); - return false; - } - }; - - // Copy nodes from the included document into the main document. - // We need to deep-copy because the nodes live in a different arena. - let included_root = included_doc.root(); - let included_children: Vec<NodeId> = included_doc.children(included_root).collect(); - - // Get the parent of the xi:include element so we can insert siblings. - let parent = doc.parent(include_node); - - // Insert each child of the included document's root before the - // xi:include element, then remove the xi:include element. - let mut inserted_nodes = Vec::new(); - for inc_child in &included_children { - let new_node = deep_copy_node(doc, &included_doc, *inc_child); - inserted_nodes.push(new_node); - } - - // Insert all new nodes before the include element. - for new_node in &inserted_nodes { - doc.insert_before(include_node, *new_node); - } - - // Detach and discard the xi:include element. - doc.detach(include_node); - - // Recursively process XInclude elements in the newly inserted content. - if parent.is_some() { - // We only need to process the newly inserted nodes. - for new_node in inserted_nodes { - process_node(doc, new_node, resolver, state, depth + 1); - } - } - - true -} - -/// Processes a text include: creates a text node with the content and replaces -/// the `xi:include` element. -/// -/// Returns `true` on success. -fn process_text_include(doc: &mut Document, include_node: NodeId, content: &str) -> bool { - let text_node = doc.create_node(NodeKind::Text { - content: content.to_string(), - }); - - doc.insert_before(include_node, text_node); - doc.detach(include_node); - - true -} - -/// Tries to use an `xi:fallback` child of the include element. -/// -/// If a fallback is found, its children are moved to replace the `xi:include` -/// element. Returns `true` if a fallback was found and applied. -fn try_fallback<F>( - doc: &mut Document, - include_node: NodeId, - resolver: &F, - state: &mut ProcessingState, - depth: usize, -) -> bool -where - F: Fn(&str) -> Option<String>, -{ - // Find the first xi:fallback child. - let fallback_node = { - let children: Vec<NodeId> = doc.children(include_node).collect(); - children - .into_iter() - .find(|&child| is_fallback_element(doc, child)) - }; - - let Some(fallback) = fallback_node else { - return false; - }; - - // Collect the fallback's children. - let fallback_children: Vec<NodeId> = doc.children(fallback).collect(); - - // Detach each fallback child and insert before the xi:include element. - let mut inserted_nodes = Vec::new(); - for child in fallback_children { - doc.detach(child); - doc.insert_before(include_node, child); - inserted_nodes.push(child); - } - - // Remove the xi:include element (which still contains the now-empty fallback). - doc.detach(include_node); - - // Recursively process the inserted fallback content. - for node in inserted_nodes { - process_node(doc, node, resolver, state, depth + 1); - } - - true -} - -/// Deep-copies a node (and all its descendants) from one document's arena -/// into another. -/// -/// This is necessary because nodes in different `Document`s live in separate -/// arenas and cannot share `NodeId`s. -fn deep_copy_node(target: &mut Document, source: &Document, source_id: NodeId) -> NodeId { - let source_node = source.node(source_id); - let new_id = target.create_node(source_node.kind.clone()); - - // Recursively copy children. - let children: Vec<NodeId> = source.children(source_id).collect(); - for child_id in children { - let new_child = deep_copy_node(target, source, child_id); - target.append_child(new_id, new_child); - } - - new_id -} - -/// Splits a URI into the base part and optional fragment identifier. -/// -/// For example, `"file.xml#section1"` returns `("file.xml", Some("section1"))`. -/// If there is no fragment, returns `(href, None)`. -fn split_fragment(href: &str) -> (&str, Option<&str>) { - if let Some(pos) = href.find('#') { - let (base, frag) = href.split_at(pos); - // frag starts with '#', skip it. - (base, Some(&frag[1..])) - } else { - (href, None) - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - - // Helper: parse XML, process XIncludes with the given resolver, return the - // document and result. - fn process_with_resolver<F>(xml: &str, resolver: F) -> (Document, XIncludeResult) - where - F: Fn(&str) -> Option<String>, - { - let mut doc = Document::parse_str(xml).unwrap(); - let result = process_xincludes(&mut doc, resolver, &XIncludeOptions::default()); - (doc, result) - } - - // Helper: serialize the document to a string for comparison. - fn doc_text_content(doc: &Document) -> String { - let root_elem = doc.root_element().unwrap(); - doc.text_content(root_elem) - } - - #[test] - fn test_basic_xml_include() { - let xml = - r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="inc.xml"/></doc>"#; - let (doc, result) = process_with_resolver(xml, |href| match href { - "inc.xml" => Some("<greeting>hello</greeting>".to_string()), - _ => None, - }); - - assert_eq!(result.inclusions, 1); - assert!(result.errors.is_empty()); - - // The included <greeting> element should be a child of <doc>. - let root = doc.root_element().unwrap(); - let children: Vec<NodeId> = doc.children(root).collect(); - assert_eq!(children.len(), 1); - assert_eq!(doc.node_name(children[0]), Some("greeting")); - assert_eq!(doc.text_content(children[0]), "hello"); - } - - #[test] - fn test_basic_text_include() { - let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="msg.txt" parse="text"/></doc>"#; - let (doc, result) = process_with_resolver(xml, |href| match href { - "msg.txt" => Some("Hello, World!".to_string()), - _ => None, - }); - - assert_eq!(result.inclusions, 1); - assert!(result.errors.is_empty()); - assert_eq!(doc_text_content(&doc), "Hello, World!"); - } - - #[test] - fn test_fallback_when_resource_not_found() { - let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="missing.xml"><xi:fallback><alt>fallback content</alt></xi:fallback></xi:include></doc>"#; - let (doc, result) = process_with_resolver(xml, |_| None); - - assert_eq!(result.inclusions, 0); - assert!(result.errors.is_empty()); - - let root = doc.root_element().unwrap(); - let children: Vec<NodeId> = doc.children(root).collect(); - assert_eq!(children.len(), 1); - assert_eq!(doc.node_name(children[0]), Some("alt")); - assert_eq!(doc.text_content(children[0]), "fallback content"); - } - - #[test] - fn test_fallback_with_text_content() { - let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="missing.xml"><xi:fallback>plain fallback</xi:fallback></xi:include></doc>"#; - let (doc, result) = process_with_resolver(xml, |_| None); - - assert_eq!(result.inclusions, 0); - assert!(result.errors.is_empty()); - assert_eq!(doc_text_content(&doc), "plain fallback"); - } - - #[test] - fn test_missing_href_attribute() { - let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include/></doc>"#; - let (_doc, result) = process_with_resolver(xml, |_| None); - - assert_eq!(result.inclusions, 0); - assert_eq!(result.errors.len(), 1); - assert!(result.errors[0].message.contains("missing required 'href'")); - assert!(result.errors[0].href.is_none()); - } - - #[test] - fn test_circular_inclusion_detection() { - // "a.xml" includes "b.xml" which includes "a.xml" again. - let xml = - r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="a.xml"/></doc>"#; - let (_, result) = process_with_resolver(xml, |href| match href { - "a.xml" => Some( - r#"<a xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="a.xml"/></a>"# - .to_string(), - ), - _ => None, - }); - - // The first include succeeds, the second (circular) fails. - assert_eq!(result.inclusions, 1); - assert_eq!(result.errors.len(), 1); - assert!(result.errors[0].message.contains("circular inclusion")); - } - - #[test] - fn test_max_depth_exceeded() { - let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="deep.xml"/></doc>"#; - let mut doc = Document::parse_str(xml).unwrap(); - let opts = XIncludeOptions { max_depth: 2 }; - - // Each level includes another level. - let result = process_xincludes( - &mut doc, - |href| { - match href { - "deep.xml" => Some( - r#"<level xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="deeper.xml"/></level>"# - .to_string(), - ), - "deeper.xml" => Some( - r#"<level xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="deepest.xml"/></level>"# - .to_string(), - ), - "deepest.xml" => Some("<leaf/>".to_string()), - _ => None, - } - }, - &opts, - ); - - // depth 0 -> deep.xml succeeds, depth 1 -> deeper.xml succeeds, - // depth 2 -> deepest.xml exceeds max_depth=2. - assert!(result.errors.iter().any(|e| e.message.contains("depth"))); - } - - #[test] - fn test_multiple_includes_in_same_document() { - let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="a.xml"/><xi:include href="b.xml"/></doc>"#; - let (doc, result) = process_with_resolver(xml, |href| match href { - "a.xml" => Some("<first/>".to_string()), - "b.xml" => Some("<second/>".to_string()), - _ => None, - }); - - assert_eq!(result.inclusions, 2); - assert!(result.errors.is_empty()); - - let root = doc.root_element().unwrap(); - let children: Vec<NodeId> = doc.children(root).collect(); - assert_eq!(children.len(), 2); - assert_eq!(doc.node_name(children[0]), Some("first")); - assert_eq!(doc.node_name(children[1]), Some("second")); - } - - #[test] - fn test_nested_includes() { - let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="outer.xml"/></doc>"#; - let (doc, result) = process_with_resolver(xml, |href| { - match href { - "outer.xml" => Some( - r#"<outer xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="inner.xml"/></outer>"# - .to_string(), - ), - "inner.xml" => Some("<inner>nested</inner>".to_string()), - _ => None, - } - }); - - assert_eq!(result.inclusions, 2); - assert!(result.errors.is_empty()); - - let root = doc.root_element().unwrap(); - let outer: Vec<NodeId> = doc.children(root).collect(); - assert_eq!(doc.node_name(outer[0]), Some("outer")); - - let inner: Vec<NodeId> = doc.children(outer[0]).collect(); - assert_eq!(doc.node_name(inner[0]), Some("inner")); - assert_eq!(doc.text_content(inner[0]), "nested"); - } - - #[test] - fn test_default_parse_attribute_is_xml() { - // When parse is not specified, it defaults to "xml". - let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="data.xml"/></doc>"#; - let (doc, result) = process_with_resolver(xml, |href| match href { - "data.xml" => Some("<item>value</item>".to_string()), - _ => None, - }); - - assert_eq!(result.inclusions, 1); - assert!(result.errors.is_empty()); - - let root = doc.root_element().unwrap(); - let children: Vec<NodeId> = doc.children(root).collect(); - assert_eq!(doc.node_name(children[0]), Some("item")); - } - - #[test] - fn test_include_replaces_entire_xi_include_element() { - // Verify that the xi:include element itself is completely removed. - let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><before/><xi:include href="mid.xml"/><after/></doc>"#; - let (doc, result) = process_with_resolver(xml, |href| match href { - "mid.xml" => Some("<middle/>".to_string()), - _ => None, - }); - - assert_eq!(result.inclusions, 1); - - let root = doc.root_element().unwrap(); - let names: Vec<Option<&str>> = doc.children(root).map(|c| doc.node_name(c)).collect(); - assert_eq!(names, vec![Some("before"), Some("middle"), Some("after")]); - } - - #[test] - fn test_text_include_preserves_whitespace() { - let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="ws.txt" parse="text"/></doc>"#; - let content = " line1\n line2\n"; - let (doc, result) = process_with_resolver(xml, |href| match href { - "ws.txt" => Some(content.to_string()), - _ => None, - }); - - assert_eq!(result.inclusions, 1); - assert_eq!(doc_text_content(&doc), content); - } - - #[test] - fn test_empty_include_content() { - // Including content that parses to an empty document root. - let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="empty.txt" parse="text"/></doc>"#; - let (doc, result) = process_with_resolver(xml, |href| match href { - "empty.txt" => Some(String::new()), - _ => None, - }); - - assert_eq!(result.inclusions, 1); - assert!(result.errors.is_empty()); - assert_eq!(doc_text_content(&doc), ""); - } - - #[test] - fn test_include_with_fragment_identifier() { - // Fragment identifiers are stripped for resolution; the base href - // is used to fetch the content. - let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="data.xml#section1"/></doc>"#; - let (doc, result) = process_with_resolver(xml, |href| match href { - "data.xml" => Some("<section>content</section>".to_string()), - _ => None, - }); - - assert_eq!(result.inclusions, 1); - assert!(result.errors.is_empty()); - - let root = doc.root_element().unwrap(); - let children: Vec<NodeId> = doc.children(root).collect(); - assert_eq!(doc.node_name(children[0]), Some("section")); - } - - #[test] - fn test_xinclude_namespace_detection() { - // An "include" element NOT in the XInclude namespace should be ignored. - let xml = r#"<doc><include href="should-ignore.xml"/></doc>"#; - let (_, result) = process_with_resolver(xml, |_| { - panic!("resolver should not be called for non-XInclude elements"); - }); - - assert_eq!(result.inclusions, 0); - assert!(result.errors.is_empty()); - } - - #[test] - fn test_split_fragment() { - assert_eq!(split_fragment("file.xml#sec"), ("file.xml", Some("sec"))); - assert_eq!(split_fragment("file.xml"), ("file.xml", None)); - assert_eq!(split_fragment("file.xml#"), ("file.xml", Some(""))); - assert_eq!(split_fragment("#frag"), ("", Some("frag"))); - } - - #[test] - fn test_no_fallback_records_error() { - let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="nope.xml"/></doc>"#; - let (_, result) = process_with_resolver(xml, |_| None); - - assert_eq!(result.inclusions, 0); - assert_eq!(result.errors.len(), 1); - assert!(result.errors[0].message.contains("resource not found")); - assert_eq!(result.errors[0].href.as_deref(), Some("nope.xml")); - } - - #[test] - fn test_invalid_parse_attribute() { - let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="x.xml" parse="json"/></doc>"#; - let (_, result) = process_with_resolver(xml, |_| None); - - assert_eq!(result.errors.len(), 1); - assert!(result.errors[0].message.contains("invalid parse attribute")); - } - - #[test] - fn test_xml_include_with_wrapper_element() { - // Included document has a root element with multiple children. - let xml = r#"<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="multi.xml"/></doc>"#; - let (doc, result) = process_with_resolver(xml, |href| match href { - "multi.xml" => Some("<wrapper><first/><second/></wrapper>".to_string()), - _ => None, - }); - - assert_eq!(result.inclusions, 1); - assert!(result.errors.is_empty()); - - let root = doc.root_element().unwrap(); - let children: Vec<NodeId> = doc.children(root).collect(); - // The <wrapper> element is inserted as a child of <doc>. - assert_eq!(children.len(), 1); - assert_eq!(doc.node_name(children[0]), Some("wrapper")); - - let wrapper_children: Vec<NodeId> = doc.children(children[0]).collect(); - assert_eq!(wrapper_children.len(), 2); - assert_eq!(doc.node_name(wrapper_children[0]), Some("first")); - assert_eq!(doc.node_name(wrapper_children[1]), Some("second")); - } - - #[test] - fn test_options_default() { - let opts = XIncludeOptions::default(); - assert_eq!(opts.max_depth, 50); - } - - #[test] - fn test_error_display() { - let err = XIncludeError { - message: "resource not found".to_string(), - href: Some("file.xml".to_string()), - }; - assert_eq!( - err.to_string(), - "XInclude error for 'file.xml': resource not found" - ); - - let err_no_href = XIncludeError { - message: "bad element".to_string(), - href: None, - }; - assert_eq!(err_no_href.to_string(), "XInclude error: bad element"); - } -} From d41db3521066c64c53eecbb2e81c59124dc293d8 Mon Sep 17 00:00:00 2001 From: Anderson Leal <andersonofl@gmail.com> Date: Wed, 19 Aug 2026 15:00:41 -0300 Subject: [PATCH 8/8] =?UTF-8?q?perf(browser):=20tune=20the=20release=20pro?= =?UTF-8?q?file=20=E2=80=94=2035.8=20MiB=20->=2023.0=20MiB=20(-36%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit strip drops DWARF debug info; thin LTO + one codegen unit let the optimizer dead-strip and deduplicate across crate boundaries. The tuned binary lands below main's stock build (24.1 MiB) despite the +11.7 MiB scrapling surface. No feature, dependency, or runtime behavior changes — the 45-case e2e suite passes against the LTO-built artifact. Cost: a few extra minutes per release build (dev profile untouched). --- browser/Cargo.toml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/browser/Cargo.toml b/browser/Cargo.toml index af0ff7a89..3ebbc369b 100644 --- a/browser/Cargo.toml +++ b/browser/Cargo.toml @@ -75,3 +75,13 @@ siphasher = "1.0.3" serde_json = "1" which = "8" tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "signal"] } + +# Release binary size: strip drops DWARF debug info (panic backtraces become +# unresolved addresses); thin LTO + a single codegen unit let the optimizer +# dead-strip and deduplicate across crate boundaries. Together: 35.8 -> 23.0 +# MiB, at the cost of a few extra minutes per release build. No feature, +# dependency, or runtime behavior changes. +[profile.release] +strip = true +lto = "thin" +codegen-units = 1